diff --git a/dist/docs/3.0.13/aao/aao-admins.mdx b/dist/docs/3.0.13/aao/aao-admins.mdx new file mode 100644 index 0000000000..f4ff37856d --- /dev/null +++ b/dist/docs/3.0.13/aao/aao-admins.mdx @@ -0,0 +1,60 @@ +--- +title: AAO for AAO Admins +description: Internal reference — editorial moderation, escalation triage, system settings, and member directory operations for AAO staff. +"og:title": "AdCP — AAO for AAO Admins" +noindex: true +--- + +# AAO for AAO Admins + +This page is for AAO staff with the global admin role — people on the AgenticAdvertising.org team who run editorial review, escalation triage, member ops, and system administration. It is intentionally narrow and operational. + +This page is indexed by Addie's `search_docs` so she can answer admin questions correctly, but is `noindex: true` for search engines and is hidden from the public docs sidebar. + +For end-user docs see [AAO for Members](/dist/docs/3.0.13/aao/users). For org-admin docs see [AAO for Org Admins](/dist/docs/3.0.13/aao/org-admins). For the full list of Addie's tools see the [Addie Tool Reference](/dist/docs/3.0.13/aao/addie-tools). + +## Becoming an AAO admin + +AAO admin status is granted via `ADMIN_EMAILS` env var on the deployed app and (separately) the `is_aao_admin` column on the user record. Both must be set for full access — `ADMIN_EMAILS` lets you call admin tools from Addie; `is_aao_admin` lets you use the admin web UI at `/admin`. + +## Escalation triage + +Members and visitors can ask Addie to escalate when she can't help. Escalations land in the database and a Slack notification fires to the configured escalation channel. + +- **Review the queue.** Web UI at `/admin/addie` (Escalations tab), or ask Addie *"list open escalations"* (`list_escalations`). +- **Auto-triage suggestions.** A scheduled job in `server/src/addie/jobs/escalation-triage.ts` proposes status transitions on open escalations with evidence (URL probes, related escalation IDs). Suggestions land in `escalation_triage_suggestions` and require admin accept/reject before applying — every close has an operator behind it. +- **Resolve from any thread.** *"Resolve escalation 142, paid the invoice"* (`resolve_escalation`) — `wont_do` is the right status when a request is intentionally not actionable; `resolved` means the asked-for thing happened. +- **Categories.** `capability_gap` (Addie's knowledge needs updating), `needs_human_action` (admin had to do something), `complex_request` (multi-step / judgment), `sensitive_topic` (legal/compliance/political), `other`. + +## Editorial moderation (perspectives) + +- **Review queue.** Web UI at `/admin/perspectives` shows `pending_review`. Or ask Addie *"list pending content"* (`list_pending_content`). +- **Approve / decline.** *"Approve perspective [slug]"* (`approve_content`), *"reject perspective [slug] with reason ..."* (`reject_content`). +- **Test posts.** Test perspectives published bypassing review (incident #271) come from the older publish path; the canonical path is `propose_content` → review → approve. If you see test content live without review, file an incident — that's a real bug, not a workflow. + +## Member directory ops + +- **List / get members.** *"Show member directory for [filter]"* (`list_members`, `get_member`). Public lookups are anonymous-safe; full contact details require admin. +- **Send a payment request to a prospect.** `send_payment_request` (admin-tools.ts) issues an invite that the recipient signs in to accept. Their email is bound to the resulting Stripe customer at acceptance time, never trusted from caller-supplied input. +- **Comp / discount membership.** Use `send_payment_request` with a $0 product or a coupon. For unusual cases (chapter leads, contributors), escalate or coordinate with billing manually. +- **Promote / demote within an org.** Today this is a database operation; CLI scripts under `scripts/` cover the common cases. We're working on a tool surface. + +## System settings + +- **Escalation channel.** `system_settings.escalation_channel` controls where Addie posts escalation notifications. Update via the admin web UI. +- **Working-group channels.** `system_settings.wg_channel_*` map each WG to its Slack channel. +- **Audit log.** `system_settings_audit` records every change — review at `/admin/audit`. + +## Internal flags and gates + +- `ADMIN_EMAILS` — comma-separated env var, controls Addie admin tool registration. +- `ALLOW_INSECURE_COOKIES` — dev only; do not set in prod. +- `ANTHROPIC_API_KEY` — required for Addie chat to function at all. +- `ANONYMOUS_DAILY_LIMIT_USD` — daily anonymous usage cap; gates `anonymousChat` model. + +## Things to escalate further (engineering, not ops) + +- Empty assistant messages on short follow-ups — investigation needed in `claude-client.ts`. +- Duplicate-message bug (#124) — same response posted twice. +- Stale `search_docs` index symptoms — run a probe via `npm run start` locally, call `searchDocs(query)` directly to confirm. +- Anonymous-mode tool refusal — Addie sometimes deflects to sign-in even when the tool is in `ANONYMOUS_SAFE_KNOWLEDGE_TOOLS`. Behavior gap, not a bug in the registry. diff --git a/dist/docs/3.0.13/aao/addie-tools.mdx b/dist/docs/3.0.13/aao/addie-tools.mdx new file mode 100644 index 0000000000..356344b15d --- /dev/null +++ b/dist/docs/3.0.13/aao/addie-tools.mdx @@ -0,0 +1,1708 @@ +--- +title: Addie Tool Reference +description: Every tool Addie has access to, grouped by capability set. +"og:title": "AdCP — Addie Tool Reference" +--- + +{/* This file is auto-generated by scripts/build-addie-tool-reference.ts. Do not edit by hand. Run `npm run build:addie-tools` to regenerate. */} + +# Addie Tool Reference + +This page lists every tool Addie can call. Each tool's description is the same one that ships into Addie's prompt, so the language is router-facing rather than tutorial-style — but it tells you exactly what Addie *can* do, *when* she should reach for the tool, and *what* fields it accepts. + +Tools are grouped by **capability set** (router category). The router selects one or more sets based on the user's intent, then Addie picks specific tools within those sets. A handful of tools are *always available* regardless of routing — bug-report flow, content submission, escalation — see the **Always available** section. + +If you're an integrator or admin and you want to know whether Addie can do X: search this page first. If you can't find a tool here, Addie can't do X — please don't ask her to invent one. + +## knowledge + +Search documentation, code repos, Slack history, curated resources, GitHub issues/PRs, and validate JSON against AdCP schemas for protocol questions, implementation help, roadmap/RFC lookups, and community discussions + +### `search_docs` + +Search the AdCP documentation for relevant content. Use this to answer questions about AdCP, the protocol, tools, or how things work. + +*Source: `server/src/addie/mcp/docs-search.ts`* + +### `get_doc` + +Get the full content of a specific documentation page. Use this after search_docs to read a document in detail. + +*Source: `server/src/addie/mcp/docs-search.ts`* + +### `search_slack` + +Search Slack messages from public channels in the AAO workspace. Use this when you need community discussions, Q&A threads, or real-world implementation examples. When asked about a specific channel or working group (e.g., "Governance working group"), use the channel parameter to filter results. When asked to summarize discussions, search for relevant keywords then synthesize the results. Cite the Slack permalink when using information from results. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `get_channel_activity` + +Get recent messages from a specific Slack channel. Use this when asked to summarize channel activity, see what a working group has been discussing, or get an overview of conversations in a channel. Returns messages sorted by recency. After getting results, synthesize them into a summary for the user. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `search_resources` + +Search curated external resources (articles, blog posts, industry content) that have been indexed with summaries and contextual analysis. Use this for industry trends, competitor info, and external perspectives on agentic advertising. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `get_recent_news` + +Get recent news and articles about ad tech and agentic advertising from curated industry feeds. Returns articles sorted by recency with summaries and analysis. Use this when users ask "what's happening in the news?", "what's new in ad tech?", or "what have we learned lately?" + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `fetch_url` + +Fetch and read the content of a web URL. Use this when a user shares a link and asks about it, or when you need to read external content. Returns the text content of the page. Note: Does not work for pages requiring authentication. + +*Source: `server/src/addie/mcp/url-tools.ts`* + +### `read_slack_file` + +Download and read a file that was shared in Slack. PROACTIVELY use this whenever a user shares a file (PDF, document, text file, image, etc.) - don't wait to be asked. Users expect you to see what they shared. Provide the file URL from the shared file info. + +*Source: `server/src/addie/mcp/url-tools.ts`* + +### `list_github_issues` + +Search or list GitHub issues and PRs to find open items on a topic, check RFC/epic status, or answer "what is being worked on for X" questions. Pass `query` for keyword search (GitHub search syntax, but `repo:`/`org:`/`user:`/`is:` qualifiers are rejected — use the `repo` param instead). Returns title, number, state, labels, author, last-updated. Do NOT use when the user has a specific issue number — use get_github_issue. Allowed repos: any `adcontextprotocol/*` or `prebid/*`. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `validate_json` + +Validate a JSON object against an AdCP schema. Use this to verify if user-provided JSON is valid according to the specification. Returns validation errors if invalid. + +*Source: `server/src/addie/mcp/schema-tools.ts`* + +### `get_schema` + +Fetch and display an AdCP JSON schema. Use this to show the exact schema definition, including all properties, required fields, and constraints. This is the authoritative source for what fields are valid. + +*Source: `server/src/addie/mcp/schema-tools.ts`* + +### `list_schemas` + +List available AdCP schemas and versions. Use this to help users discover what schemas exist and what versions are available. + +*Source: `server/src/addie/mcp/schema-tools.ts`* + +### `compare_schema_versions` + +Compare two schema versions to show what changed. Use this when users ask about differences between AdCP versions or are confused about which version to use. + +*Source: `server/src/addie/mcp/schema-tools.ts`* + +## member + +Manage member profile, working groups, committees, and account settings. Includes listing working group documents, attaching assets to content, and updating the company logo or brand color. + +### `get_my_profile` + +Get the current user's personal profile — who they are as a person. Shows headline, bio, expertise, interests, and social links. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `update_my_profile` + +Update the current user's personal profile — who they are as a person. Can update headline, bio, expertise, interests, location, and social links. Only updates fields that are provided. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_company_listing` + +Get the company's directory listing — how the organization appears in the member directory and to Addie. Shows tagline, description, offerings, headquarters, and contact info. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `update_company_listing` + +Update the company's directory listing text fields — tagline, description, contact info, social links, and headquarters. Only updates fields that are provided. For logo or brand color, use update_company_logo instead. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `update_company_logo` + +Update the company logo or brand color on the directory listing. Use when a member wants to upload, change, or fix their company logo. The logo URL must be a publicly accessible HTTPS image (PNG, JPG, SVG, etc.) — file-viewer links like Google Drive don't work. + +If the brand domain was previously registered by another organization, the tool returns a notice asking the user whether to adopt the prior brand identity (logos, colors, agents) or start fresh — pass `adopt_prior_manifest: true` to adopt or `false` to clear, then call again. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `request_brand_domain_challenge` + +Issue a DNS TXT challenge so the caller's organization can claim a brand domain currently registered to another org or unregistered. Returns the verification record (Name/Type/Value) for the user to publish at their DNS host. DO NOT use when: the domain is already owned by the caller's org (already linked in their member profile); the user is just asking what their domain is; the user is asking generic 'is my domain set up?' questions. Pair with verify_brand_domain_challenge ONLY after the user confirms they've published the record. Response begins with an HTML comment '<!-- STATUS: <code> -->' for machine parsing (invisible in rendered markdown) — codes: dns_record_issued, already_verified, collision, invalid_domain, workos_error, not_authenticated, no_org, not_admin, missing_domain. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `verify_brand_domain_challenge` + +Run the WorkOS DNS lookup against a previously-issued challenge and, on success, apply the brand-registry update. ONLY call after request_brand_domain_challenge returned DNS instructions in this same conversation AND the user has explicitly confirmed they published the record. NEVER call speculatively, as a 'check status' tool, or in a retry loop — DNS propagation takes minutes and the server enforces a cooldown that will return still_pending if you call again too soon. If the call returns still_pending, STOP and ask the user to confirm before any retry. Response begins with an HTML comment '<!-- STATUS: <code> -->' (invisible in rendered markdown) — codes: verified, still_pending, no_challenge, workos_error, not_authenticated, no_org, not_admin, missing_domain. After 'verified' the claim is complete; after 'still_pending' STOP and ask the user to confirm before retrying. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `list_working_groups` + +List active committees in AgenticAdvertising.org. Can filter by type: working groups (technical), councils (industry verticals), or chapters (regional). Shows public groups to everyone, and includes private groups for members. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_working_group` + +Get details about a specific working group including its description, leaders, member count, and recent posts. Use the group slug (URL-friendly name). Pass include_members: true to get the full member list with names, org, and email (admins only for private groups). + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `join_working_group` + +Join a working group on behalf of the current user. If the group is private, suggests using request_working_group_invitation instead. The user must be a member of AgenticAdvertising.org. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `request_working_group_invitation` + +Request an invitation to a private working group on behalf of the user. Creates an escalation so an admin can process the invite. Use this when join_working_group fails because a group is private. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_my_working_groups` + +Get the current user's working group memberships. Shows which groups they belong to and their role in each. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `express_council_interest` + +Express interest in joining an industry council or other committee that is not yet launched. The user can indicate whether they want to be a participant or a potential leader. This helps gauge interest before the council officially launches. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `withdraw_council_interest` + +Withdraw interest in a council or committee. Use this when the user no longer wants to be notified when the council launches. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_my_council_interests` + +Get the current user's council interest signups. Shows which councils they've expressed interest in joining. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `list_perspectives` + +List published perspectives (articles/posts) from AgenticAdvertising.org members. These are public articles shared by the community. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `create_working_group_post` + +Create a post in a working group on behalf of the current user. The user must be a member of the working group. Supports article, link, and discussion post types. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `attach_content_asset` + +Attach a file (image, PDF) to a published perspective. Fetches from a URL and stores it. Use after propose_content to add cover images or report PDFs. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `bookmark_resource` + +Save a useful web resource to the knowledge base for future reference. Use this when you find valuable external content during web search that would be helpful for future questions. The content will be fetched, summarized, and indexed. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `list_committee_documents` + +List documents tracked by a committee. Shows document titles, status, and summaries. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## directory + +The searchable partner/vendor directory — find partners, vendors, consultants, service providers, and member organizations. Also: request introductions, browse the member directory, research brands, look up brand assets, and find registry gaps + +### `search_members` + +Search for member ORGANIZATIONS (companies) that offer specific capabilities or services. Searches member names, descriptions, taglines, offerings, and tags. Use this when users want to find vendors, consultants, implementation partners, or managed services. The query should reflect what the user actually needs (e.g., "CTV measurement", "sales agent implementation") — not a generic term like "partner". Returns public member profiles with contact info. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `request_introduction` + +Send an introduction email connecting a user with a member organization. Addie sends the email directly on behalf of the requester. Use this when a user explicitly asks to be introduced to or connected with a specific member after seeing search results. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_my_search_analytics` + +Get search analytics for the user's member profile. Shows how many times their profile appeared in searches, profile clicks, and introduction requests. Only works for members with a public profile. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `list_members` + +List AgenticAdvertising.org member organizations. Can filter by offerings (buyer_agent, sales_agent, creative_agent, signals_agent, si_agent, governance_agent, publisher, consulting), markets (North America, EMEA, APAC, LATAM, Global), or search term. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `get_member` + +Get detailed information about a specific AAO member by their slug identifier. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `list_agents` + +List all public AdCP agents from member organizations. Can filter by type: creative (asset generation), signals (audience data), sales (media buying), governance (property lists and content standards), or si (sponsored intelligence/conversational commerce). + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `get_agent` + +Get details for a specific agent by its URL. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `list_publishers` + +List all publishers that have published a /.well-known/adagents.json file, indicating they support AdCP. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `lookup_domain` + +Find all agents authorized for a specific publisher domain. Shows both verified agents (from adagents.json) and claimed agents (from agent registrations). + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `research_brand` + +Research a brand by domain using Brandfetch API. Returns brand info (logo, colors, company details) if found. Automatically saves enrichment data to the registry — no need to call save_brand after. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `resolve_brand` + +Resolve a domain to its canonical brand identity by checking for brand.json at /.well-known/brand.json. Returns the authoritative brand info if found. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `save_brand` + +Save a brand to the registry as a community brand. Use for manually adding brands (not needed after research_brand, which auto-saves). Preserves any existing enrichment data when manifest is not provided. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `list_brands` + +List brands in the registry with optional filters. Can filter by source type and search by name or domain. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `list_missing_brands` + +List the most-requested brand domains that are not yet in the registry. Shows demand signals — which brands people are looking for but we don't have. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +## agent_testing + +Publisher and agent setup, verification, and testing — validate adagents.json, check brand.json, verify publisher authorization, resolve properties, probe agent endpoints, run compliance tests, grade RFC 9421 request signing, and diagnose OAuth handshakes. Use for any "my agent can't see properties", "authorization not working", "is my signing setup correct?", "diagnose OAuth", or publisher setup questions. + +### `validate_adagents` + +Validate a domain's /.well-known/adagents.json file. Returns validation results including any errors or warnings. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `resolve_brand` + +Resolve a domain to its canonical brand identity by checking for brand.json at /.well-known/brand.json. Returns the authoritative brand info if found. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `probe_adcp_agent` + +Check if an AdCP agent is online and list its advertised capabilities. This only verifies connectivity (the agent responds to HTTP requests) - it does NOT verify the agent implements the protocol correctly. Use evaluate_agent_quality to verify actual protocol compliance. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `check_publisher_authorization` + +Check if a publisher domain has authorized a specific agent. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `test_adcp_agent` + +Deprecated — use evaluate_agent_quality instead. Runs evaluate_agent_quality and returns the same results. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `evaluate_agent_quality` + +Run protocol compliance evaluation on an AdCP agent and return structured results for coaching. Tests all capability tracks the agent supports (core, products, media buy, creative, governance, signals, etc.) and collects advisory observations about performance, completeness, and best practices. Results include specific actionable observations, not just pass/fail. The public test agent works for any logged-in user with no setup required. For custom agents requiring authentication, use save_agent first. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `grade_agent_signing` + +Run the RFC 9421 request-signing conformance grader against an agent. Tests whether the agent's verifier accepts valid signed requests and rejects unsigned, expired, replayed, wrong-key, etc. requests with the right error codes. Returns a per-vector pass/fail report with diagnostics. Preconditions: the agent declares `request_signing.supported: true` in get_adcp_capabilities and has its verifier preconfigured per `test-kits/signed-requests-runner.yaml` (accepts the runner's signing keyids `test-ed25519-2026` and `test-es256-2026`, has `test-revoked-2026` in its revocation list). Live-side-effect vectors (real `create_media_buy`, replay-cap flood) are skipped by default — pass `allow_live_side_effects: true` to run them, and only do that against a sandbox endpoint. + +*Source: `server/src/addie/mcp/auth-grader-tools.ts`* + +### `diagnose_agent_auth` + +Diagnose an agent's OAuth handshake by probing RFC 9728 protected-resource metadata and RFC 8414 authorization-server metadata, decoding any access token in scope, and reporting ranked hypotheses about what's wrong (likely / possible / ruled out). Use when an agent returns 401/403 unexpectedly, when OAuth metadata might be misconfigured, or when validating an agent's OAuth setup before integrating. This is anonymous-mode diagnosis — token refresh and authenticated tool-call probes are skipped, so the report describes what the public surface advertises rather than whether a specific token works. + +*Source: `server/src/addie/mcp/auth-grader-tools.ts`* + +### `compare_media_kit` + +[DEPRECATED — use test_rfp_response or test_io_execution instead] Compare a publisher's stated inventory against what their agent returns. Prefer test_rfp_response (tests against real RFPs) or test_io_execution (tests whether IOs can execute through the agent). + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `test_rfp_response` + +Test how a publisher's agent responds to a real RFP or campaign brief. Addie parses the RFP document first, then calls this tool with structured data. Calls get_products on the agent and returns gap analysis comparing what the agent surfaces vs what the RFP requests. The publisher's stated response (what they'd normally propose) is the highest-value input — it lets you compare agent output to how the sales team actually responds. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `test_io_execution` + +Test whether a buyer agent can execute a real IO or proposal through the publisher's agent. Addie parses the IO document first, then calls this tool with structured line items. Maps each line item to agent products using deterministic scoring, constructs the exact create_media_buy JSON a buyer agent would send, and optionally dry-runs it. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `validate_agent` + +Validate if an agent is authorized for a publisher domain by checking their /.well-known/adagents.json file. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `resolve_property` + +Resolve a publisher domain to its property information. Checks hosted properties, discovered properties, and live adagents.json. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `save_property` + +Save or approve a hosted property in the registry. Creates new properties, approves pending ones, or updates existing ones. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `list_properties` + +List publisher properties in the registry. Can filter by source type and search by domain. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `list_missing_properties` + +List the most-requested publisher domains that are not yet in the registry. Shows demand signals — which properties people are looking for but we don't have. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +## adcp_operations + +Execute AdCP protocol operations - discover documentation, execute tasks against agents, check agent capabilities. Covers media buy, creative, signals, governance, SI, and brand protocol. + +### `save_agent` + +Save an agent URL to the organization's context and add it to the dashboard for compliance monitoring. New agents land in the dashboard with `members_only` visibility — discoverable to fellow Professional-tier (or higher) members, but not publicly listed in the directory or brand.json. To list publicly, the caller promotes the agent via the dashboard publish flow; that flow gates on an API-access subscription tier. Optionally store credentials securely (encrypted, never shown in conversations). Three auth modes, any of which may be combined with a new or existing save: (1) static bearer/basic via `auth_token`, (2) OAuth 2.0 client credentials (RFC 6749 §4.4, machine-to-machine) via `oauth_client_credentials`. Use this when users want to connect their agent, set up compliance monitoring, save their agent for testing, or provide credentials. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `list_saved_agents` + +List all agents saved for this organization. Shows agent URLs, names, types, and whether they have auth tokens stored (but never shows the actual tokens). Use this when users ask "what agents do I have saved?" or want to see their configured agents. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `remove_saved_agent` + +Remove a saved agent and its stored auth token. Use this when users want to delete or forget an agent configuration. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `setup_test_agent` + +Save the public AdCP test agent credentials for the user's organization so teammates can use them. Any logged-in user can already use the public test agent directly via evaluate_agent_quality without this step — no organization required. This is only needed for teams that want credentials stored. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## content + +Manage content workflows — propose news sources, add or update committee documents (admin actions) + +### `propose_news_source` + +Propose a website or RSS feed as a news source for industry monitoring. Any community member can propose sources - admins will review and approve them. Use this when someone shares a link to a relevant ad-tech, marketing, or media publication and thinks it should be monitored for news. Check for duplicates before proposing. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `add_committee_document` + +Add a Google Docs document to a committee (working group, council, or chapter) for tracking. The document will be automatically indexed and summarized. Committee members and leaders can add documents. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `update_committee_document` + +Update a document tracked by a committee. Can change title, description, URL, or featured status. Committee members and leaders can update documents. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `delete_committee_document` + +Remove a document from a committee. The document will no longer be tracked or displayed. Only committee leaders can delete documents. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## billing (admin only) + +Handle billing and payment operations - create payment links, send invoices, manage discounts and promotions, look up pending invoices + +### `find_membership_products` + +Find available membership products for a potential member. +Use this when someone asks about joining, membership pricing, or wants to become a member. +You should ask about their company type and approximate revenue to find the right product. + +*Source: `server/src/addie/mcp/billing-tools.ts`* + +### `create_payment_link` + +Create a Stripe checkout payment link for the authenticated member's own organization. +The link is issued to the signed-in member only — the customer email and identity are taken from the +authenticated session, never from caller-supplied input. The member must be signed in at +agenticadvertising.org and have a workspace; if not, refuse and direct them to sign up first. +This tool cannot generate payment links on behalf of other people or organizations. + +*Source: `server/src/addie/mcp/billing-tools.ts`* + +### `send_invoice` + +Preview an invoice for the authenticated member's own organization so they can +confirm the amount and billing email before it is sent. The contact email and company are taken from +the signed-in session, never from caller-supplied input. After calling this and the member confirms, +call confirm_send_invoice to send. + +*Source: `server/src/addie/mcp/billing-tools.ts`* + +### `send_payment_request` + +Find or create a prospect organization and either look up its products, draft an invoice for review, or send a membership invite. Admins cannot directly mint payment links or send invoices from this tool — those operations are only valid in the recipient's own authenticated session, after they accept the invite. + +Actions: +- "lookup_only": find or create the org, list eligible products. Read-only. +- "draft_invoice": preview what the invoice would look like (amount, discount). No Stripe write. +- "send_invite": create a membership invite token and email it to the contact. This is NOT a direct invoice send — there is no admin path to issue invoices or payment links to non-signed-in recipients. The recipient signs in, accepts the agreement, and the invoice or checkout is then issued in their authenticated session — never under the admin's or a hallucinated email. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `grant_discount` + +Grant a discount to an organization. Creates Stripe coupon/promo code. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `remove_discount` + +Remove a discount from an organization. Note: This does not delete any Stripe coupons that were created. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_discounts` + +List organizations with active discounts. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `create_promotion_code` + +Create a standalone Stripe promo code for marketing campaigns. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `resend_invoice` + +Resend an open invoice. Provide EITHER an invoice_id (if known) OR a company_name to look up their pending invoices. If the company has exactly one open invoice, it will be resent automatically. If the invoice needs to go to a different email, use update_billing_email first. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_billing_email` + +Update the billing email on a Stripe customer. Use this when invoices need to go to a different email address (e.g., accounts payable). Can look up by org_id or direct customer_id. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_pending_invoices` + +List all organizations with pending (unpaid) invoices. +Use this when an admin asks about outstanding invoices or payment status across organizations. +Returns a list of organizations with open or draft invoices. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_account` + +Get complete account view for any organization: lifecycle stage, membership status, engagement metrics, pipeline info, and enrichment data. Use for any company lookup. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +## events + +Browse upcoming events, check event registrations, get event details, see who is coming, and register interest in events — available to all members + +### `list_events` + +List AAO events personalized for the user. Shows: +- Events the user is already registered for +- Events in regional chapters they're a member of +- Events at industry gatherings they've indicated interest in (CES, Cannes Lions, etc.) +- Major global summits (open to all members) + +Does NOT include virtual webinars (those are educational content). + +When asked about past events, set include_past=true. +When asked about upcoming events or what's happening soon, use the defaults. + +If the user isn't in any regional chapters or hasn't indicated interest in any industry events, +the response will suggest they share their location or join industry gathering groups. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `get_event_details` + +Get details about a specific event including registration count and waitlist. Use this when someone asks about a specific event. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `list_event_attendees` + +List who is registered for an event. Use when someone asks "who's coming to [event]?", "who's registered?", "attendee list", or "who will be there?". Shows names of registered attendees for published events. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `register_event_interest` + +Register the current user's interest in an event. Use when someone asks to be notified about an event, added to a waitlist, or wants to express interest without formally registering. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +## meetings + +Schedule, list, update, and cancel meetings - add or remove attendees, RSVP, manage recurring series, handle calendar invites and Zoom links + +### `schedule_meeting` + +Schedule a new working group meeting. Use this when someone asks to schedule a meeting, call, or discussion. +The meeting will be created with a Zoom link. For one-time meetings, calendar invites are sent to working group members by default. + +For recurring meetings, calendar invites are sent to working group members by default (same as one-time meetings). + +If the user is in a channel associated with a working group, you can omit working_group_slug and it will be inferred from the channel context. + +For recurring meetings, use the recurrence parameter with freq, interval, count, and byDay. + +Required: title, start_time (ISO format without timezone suffix - use timezone parameter for that) +Optional: working_group_slug (auto-detected from channel), description, agenda, duration_minutes, timezone, topic_slugs, recurrence + +IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z suffix. For example, if user says "2pm ET", use "2026-01-15T14:00:00" (not "2026-01-15T14:00:00Z"). The timezone parameter (default: America/New_York) specifies what timezone the start_time is in. + +Example prompts this handles: +- "Schedule a technical working group call for next Tuesday at 2pm ET" +- "Set up a bylaws subcommittee meeting for Jan 15 at 3pm PT" +- "Schedule weekly governance calls every Thursday at 3pm for the next 8 weeks" +- "Create a recurring creative WG meeting every other Tuesday at 2pm" + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `list_upcoming_meetings` + +List upcoming meetings. Use this when someone asks about scheduled meetings, what's coming up, or the meeting calendar. Also use this as a first step when you need a meeting_id for add_meeting_attendee, cancel_meeting, or update_meeting. Use my_committees_only to filter to committees the user is a member of. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `get_my_meetings` + +Get the user's upcoming meetings. Use this when someone asks "what meetings do I have?" or "what's on my calendar?" + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `get_meeting_details` + +Get details about a specific meeting including attendees and RSVP status. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `rsvp_to_meeting` + +RSVP to a meeting. Use this when someone says they want to attend a meeting or needs to decline. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `cancel_meeting` + +Cancel a scheduled meeting. Sends cancellation notices to all attendees. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `cancel_meeting_series` + +Cancel a recurring meeting series. Cancels all upcoming meetings in the series (Zoom + calendar) and archives the series record. Use this when someone wants to stop a recurring series entirely. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `update_meeting` + +Update an existing meeting's details. Use this when someone wants to change the time, title, description, or agenda of a scheduled meeting. + +This will update the meeting in the database, Zoom (if configured), and Google Calendar. + +IMPORTANT: For start_time, provide the time in the user's timezone WITHOUT a Z suffix (same as schedule_meeting). + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `add_meeting_attendee` + +Add a person to an existing meeting by email. Call this once per person. Use list_upcoming_meetings first to get the meeting_id. + +Example: "add Karen, Brian, and Jonathan to the call" requires: +1. list_upcoming_meetings to find the meeting_id +2. add_meeting_attendee for Karen +3. add_meeting_attendee for Brian +4. add_meeting_attendee for Jonathan + +When add_to_series is true, adds them to all upcoming meetings in the same series. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `update_topic_subscriptions` + +Update meeting topic subscriptions for a user in a working group. Use this when someone wants to change which types of meetings they're invited to. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +### `manage_committee_topics` + +Manage topics for a working group/committee. Topics help organize meetings and filter invitations. Each topic can optionally have its own Slack channel for subgroup discussions. Use action='list' to see current topics, action='add' to create a new topic, action='update' to modify an existing topic, or action='remove' to delete a topic. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +## committee_leadership + +Manage committee co-leaders - add or remove co-leaders for committees you lead (working groups, councils, chapters, industry gatherings) + +### `add_committee_co_leader` + +Add a co-leader to a committee you lead. Use this when a committee leader wants to add another person to help lead their committee. + +Works for working groups, councils, chapters, and industry gatherings. + +IMPORTANT: You can only add co-leaders to committees where you are already a leader. + +Example uses: +- "Add Sarah as a co-leader for the India Chapter" +- "I want to add John to help lead the Creative Working Group" +- "Add Maria to the CTV Council leadership" + +*Source: `server/src/addie/mcp/committee-leader-tools.ts`* + +### `remove_committee_co_leader` + +Remove a co-leader from a committee you lead. The person will remain a member but lose leadership access. + +Works for working groups, councils, chapters, and industry gatherings. + +IMPORTANT: You can only remove co-leaders from committees where you are a leader. +You cannot remove yourself as a leader (contact admin for that). + +*Source: `server/src/addie/mcp/committee-leader-tools.ts`* + +### `list_committee_co_leaders` + +List all current leaders of a committee you lead. Shows who has leadership access. + +Works for working groups, councils, chapters, and industry gatherings. + +*Source: `server/src/addie/mcp/committee-leader-tools.ts`* + +### `list_working_groups` + +List active committees in AgenticAdvertising.org. Can filter by type: working groups (technical), councils (industry verticals), or chapters (regional). Shows public groups to everyone, and includes private groups for members. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## admin (admin only) + +Administrative operations - manage prospects, organizations, feeds, escalations, user roles, committee/working group leadership, event management (create/update events, manage registrations, invites, attendee lists), member insights and engagement analytics, community-wide engagement ranking, brand logo registry review queue (approve/reject pending logos), edit a member's directory profile or logo on their behalf (admin only) + +### `create_event` + +Create a new AAO event. Use this when someone asks to create a meetup, webinar, summit, or workshop. +The event will be created in both Luma (for registration) and the AAO website. +Returns the Luma URL for sharing and the AAO event page URL. + +Required: title, start_time (ISO format), event_type +Optional: description, end_time, timezone, location details, virtual_url, max_attendees + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `update_event` + +Update an existing event. Use this to change event details like description, capacity, or timing. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `manage_event_registrations` + +Manage event registrations - view registrations, approve waitlisted attendees, or export attendee list. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `check_person_event_status` + +Look up a specific person's status at an event. Use when someone asks "Is [person] invited to [event]?", "Did [person] attend [event]?", "What's [person]'s RSVP status?", etc. + +Searches by name or email across invite list, registrations, and attendance records. +Returns their invite status, registration status, and whether they attended. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `invite_to_event` + +Invite a person to an event. Adds them to the invite list and creates a registration record. + +Use when someone says "Invite [person] to [event]", "Add [person] to the Foundry guest list", etc. + +If draft_message is true, returns a suggested outreach message that can be sent via Slack or email. +The invitation is recorded immediately; the outreach message is a draft for the admin to review. + +*Source: `server/src/addie/mcp/event-tools.ts`* + +### `list_pending_invoices` + +List all organizations with pending (unpaid) invoices. +Use this when an admin asks about outstanding invoices or payment status across organizations. +Returns a list of organizations with open or draft invoices. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_account` + +Get complete account view for any organization: lifecycle stage, membership status, engagement metrics, pipeline info, and enrichment data. Use for any company lookup. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `add_prospect` + +Add a new prospect organization to track. Use get_account first to confirm the company does not exist. Capture as much info as possible: name, domain, contact details, and notes about their interest. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_prospect` + +Update information about an existing prospect. Use this to add notes, change status, update contact info, or set interest level. IMPORTANT: When adding notes that indicate excitement, resource commitment, or intent to join, also set interest_level accordingly. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `enrich_company` + +Research a company using Lusha to get firmographic data (revenue, employee count, industry, etc.). Can be used with a domain or company name. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `query_prospects` + +Query prospects across different views. Use `view` to switch perspective: "all" (default), "my_engaged", "my_followups", "unassigned", or "addie_pipeline". + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `prospect_search_lusha` + +Search Lusha's database for potential prospects matching criteria. Use this to find new companies to reach out to based on industry, size, or location. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `search_industry_feeds` + +Search and list RSS industry feeds. Use this to find feeds by name, URL, or category, or to see feeds with errors that need attention. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `add_industry_feed` + +Add a new RSS feed to monitor for industry news. Provide the feed URL and a name. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_feed_stats` + +Get statistics about industry feeds - total feeds, active feeds, articles collected, processing status, etc. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_feed_proposals` + +List pending feed proposals submitted by community members. Use this to review what news sources have been proposed. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `approve_feed_proposal` + +Approve a feed proposal and create the feed. You must provide the final feed name and URL (which may differ from the proposed URL if you find the actual RSS feed). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `reject_feed_proposal` + +Reject a feed proposal. Optionally provide a reason that could be shared with the proposer. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `add_media_contact` + +Flag a Slack user as a known media contact (journalist, reporter, editor). Messages from this user will be handled with extra care and sensitive topics will be deflected. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_flagged_conversations` + +List conversations that have been flagged for sensitive topic detection. These need human review to ensure appropriate handling. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `review_flagged_conversation` + +Mark a flagged conversation as reviewed. Use this after you've looked at a flagged message and determined if any follow-up action is needed. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `create_chapter` + +Create a regional chapter with Slack channel. Sets founding member as chapter leader. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_chapters` + +List all regional chapters with their member counts and Slack channels. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `create_industry_gathering` + +Create an industry gathering for conferences/trade shows. Auto-archives after event ends. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_industry_gatherings` + +List all industry gatherings with their dates, locations, and member counts. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_working_groups` + +List active committees in AgenticAdvertising.org. Can filter by type: working groups (technical), councils (industry verticals), or chapters (regional). Shows public groups to everyone, and includes private groups for members. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_working_group` + +Get details about a specific working group including its description, leaders, member count, and recent posts. Use the group slug (URL-friendly name). Pass include_members: true to get the full member list with names, org, and email (admins only for private groups). + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `add_committee_leader` + +Add a user as leader of a committee. Leaders can manage posts, events, and members. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `remove_committee_leader` + +Remove a user from committee leadership. User remains a regular member. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_committee_leaders` + +List all leaders of a committee. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `merge_organizations` + +Merge duplicate organization records. Destructive, cannot be undone. Preview first with preview=true. If both orgs have Stripe customers, you must specify stripe_customer_resolution. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `find_duplicate_orgs` + +Search for duplicate organizations by name or domain. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `check_domain_health` + +Check domain health for data quality issues: orphan domains, conflicts, misaligned users. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `manage_organization_domains` + +Add, remove, or list verified domains for an organization. Syncs to WorkOS. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_org_member_role` + +Update a user's role within their organization. Use this to change a member's permissions. + +Common scenarios: +- User paid for membership but can't manage team → promote to admin +- Need to grant someone ability to invite team members → promote to admin +- User should have full control of their org → promote to owner + +Roles: member (default), admin (can manage team), owner (full control) + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `claim_prospect` + +Claim ownership of a prospect. Use owner_type "self" (default) to assign the current human user, or "addie" to assign Addie as SDR owner. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `triage_prospect_domain` + +Assess an email domain as a potential prospect. Addie will research the company, determine fit, and optionally create a prospect record. Use this when someone mentions a company that isn't in the system yet. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `suggest_prospects` + +Suggest companies to add to prospect list. Finds unmapped domains and Lusha matches. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `set_reminder` + +Set a reminder/next step for a prospect. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `my_upcoming_tasks` + +List upcoming tasks and reminders. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `complete_task` + +Mark a task/reminder as done. Can complete by company name, org ID, or all overdue tasks at once. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `log_conversation` + +Log a conversation or interaction with a prospect/member. Analyzes and extracts learnings. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_member_search_analytics` + +Get analytics about member searches and introductions. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_organizations_by_users` + +List organizations ranked by user count (website + Slack-only). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_users_by_engagement` + +List community members ranked by community points (earned from events, working groups, content, connections, GitHub). Shows relationship stage, organization, and points breakdown by action type. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_slack_users_by_org` + +List Slack users from a specific organization. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_paying_members` + +List all paying members grouped by subscription level ($50K ICL, $10K corporate, $2.5K SMB, individual). Includes individual members by default. Pass include_individual: false for corporate-only. Each entry includes the primary contact name and email. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `resend_invoice` + +Resend an open invoice. Provide EITHER an invoice_id (if known) OR a company_name to look up their pending invoices. If the company has exactly one open invoice, it will be resent automatically. If the invoice needs to go to a different email, use update_billing_email first. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_billing_email` + +Update the billing email on a Stripe customer. Use this when invoices need to go to a different email address (e.g., accounts payable). Can look up by org_id or direct customer_id. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `add_working_group_member` + +Add a user as a member of a working group, council, chapter, or industry gathering. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `remove_working_group_member` + +Remove a user from a working group, council, chapter, or industry gathering. The user is deactivated, not deleted. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `rename_working_group` + +Rename a working group, chapter, or committee. Updates the display name and optionally the slug. Use this when a chapter or WG needs to be renamed (e.g., "Germany Chapter" → "DACH Chapter"). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_missing_brands` + +List the most-requested brand domains that are not yet in the registry. Shows demand signals — which brands people are looking for but we don't have. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `list_missing_properties` + +List the most-requested publisher domains that are not yet in the registry. Shows demand signals — which properties people are looking for but we don't have. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `get_outreach_stats` + +Get outreach performance metrics: messages sent, response rates. Use this when asked "how is outreach going?" or about engagement performance. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_outreach_history` + +Get outreach message history. With a slack_user_id, returns that person's full outreach timeline with goals and responses. Without, returns recent system-wide outreach. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `send_outreach` + +Trigger outreach to a Slack user. Checks eligibility first. Set dry_run=true to check eligibility without sending. Use lookup_person for full person context. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `lookup_person` + +Look up a person by Slack user ID, email, or name. Returns their relationship stage, sentiment, contact eligibility, recent activity, and org. Use for person-level context (vs get_account for org-level). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_action_items` + +Get open action items from the outreach pipeline — nudges, warm leads, momentum signals, follow-ups. Shows what needs attention today. Use to answer "who needs follow-up?" or "what's in my pipeline?" + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_pending_brand_logos` + +List brand logos awaiting moderator review across the registry. Returns logo IDs, domains, uploader email, tags, and how long they have been pending. Use this to triage the registry approval queue or answer "what logos need approval?" + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_brand_logos` + +List every logo for ONE specific brand domain — pending, approved, rejected, deleted — to investigate why a brand's logo is or is not displaying. Use this when you have a domain in hand. For the global moderation queue across all domains, use list_pending_brand_logos instead. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `review_brand_logo` + +Approve, reject, or delete a pending brand logo. Approving makes it visible on the brand's company listing; rejecting hides it; deleting tombstones it. Triggers manifest rebuild on approve for unverified brands. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_member_logo` + +Set or update the logo URL on a member's directory profile. Requires a publicly hosted HTTPS logo URL plus either the member's org_name or profile slug to identify them. Creates a brand entry if none exists, or updates the existing one. + +Do not use this to upload or host logo files — the URL must already be publicly accessible. After updating, use resolve_escalation to close the related support ticket. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_member_profile` + +Update fields on a member's directory profile. Identify the member by org_name or slug (exact match required). Accepts any combination of: description, tagline, contact info, social links, headquarters, markets, offerings, and visibility settings. + +For logo changes, use update_member_logo instead. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `transfer_brand_ownership` + +Transfer ownership of a brand domain from one organization to another. Records a revision for audit. Use after out-of-band verification (acquisition docs, support ticket, legal correspondence) confirms the new org should own the domain. + +Do not use to resolve unverified disputes — use the escalation queue for those. This is for confirmed transfers only. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_orphaned_brands` + +List brand domains in the orphaned state — a prior owner relinquished and the manifest is preserved for adoption. Use this to audit relinquished brands, see which ones have stale data, and trigger admin cleanup or reach out to potential adopters. Returns prior owner org name + id, when relinquished, and a manifest preview so admins can decide at a glance. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +## outreach (admin only) + +SDR outreach operations — view outreach stats, check history, send outreach, look up people, manage action items (admin only) + +### `get_outreach_stats` + +Get outreach performance metrics: messages sent, response rates. Use this when asked "how is outreach going?" or about engagement performance. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_outreach_history` + +Get outreach message history. With a slack_user_id, returns that person's full outreach timeline with goals and responses. Without, returns recent system-wide outreach. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `send_outreach` + +Trigger outreach to a Slack user. Checks eligibility first. Set dry_run=true to check eligibility without sending. Use lookup_person for full person context. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `lookup_person` + +Look up a person by Slack user ID, email, or name. Returns their relationship stage, sentiment, contact eligibility, recent activity, and org. Use for person-level context (vs get_account for org-level). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_action_items` + +Get open action items from the outreach pipeline — nudges, warm leads, momentum signals, follow-ups. Shows what needs attention today. Use to answer "who needs follow-up?" or "what's in my pipeline?" + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_account` + +Get complete account view for any organization: lifecycle stage, membership status, engagement metrics, pipeline info, and enrichment data. Use for any company lookup. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +## collaboration + +Send direct messages to other AgenticAdvertising.org members, forward conversation context, and collaborate across the community + +### `send_member_dm` + +Send a direct message to another AgenticAdvertising.org member on Slack. + +USE THIS WHEN a user explicitly asks you to: +- Reach out to another member on their behalf +- Forward a conversation summary to someone for feedback +- Send a follow-up or notification to a specific person + +The message will include attribution showing who asked you to send it. +You can optionally include a summary of the current conversation as context. + +Look up recipients by email (preferred), name (may need disambiguation), or Slack user ID. + +DO NOT USE unless the user explicitly requests you to message someone. + +*Source: `server/src/addie/mcp/collaboration-tools.ts`* + +## certification + +AdCP Academy — list tracks, teach modules, run exercises, placement assessment, and track learner progress + +### `list_certification_tracks` + +List all AdCP certification tracks and the learner's progress in each. Returns track names, descriptions, module counts, and completion status. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `get_certification_module` + +Preview a module's content without starting it. Returns lesson plan, exercises, and assessment criteria for read-only browsing. Does NOT record progress or check prerequisites. Use start_certification_module instead when the learner wants to actually take a module. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `start_certification_module` + +Begin teaching a certification module. MUST be called BEFORE you teach any module content, run demos, or answer questions about module topics. This is not optional — teaching without starting the module means no progress is tracked, no demonstrations are recorded, and the learner gets no credit. Call this FIRST, then use the returned lesson plan to teach. Records the learner as started, checks prerequisites and membership, returns lesson plan with teaching instructions and assessment criteria. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `complete_certification_module` + +Mark a certification module as completed. Call ONLY when the learner has demonstrated mastery of ALL learning objectives. If they have gaps, keep teaching — there is no failing, only "not ready yet." Your job is to get them there, not to judge them. When you are confident they understand every objective, call this with your internal assessment scores. The learner never sees these scores — they are for admin analytics and quality calibration only. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `get_learner_progress` + +Get the current learner's progress across all certification modules and tracks. Shows which modules are completed, in progress, or not started, plus any earned certificates. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `test_out_modules` + +Mark modules as tested out after a placement assessment confirms the learner already has the knowledge. Only call this after conducting a thorough assessment — ask probing questions per module topic, not just surface-level familiarity. Never test out specialist or build project modules (S1-S5, B4, C4, D4). Does not award scores since no formal coursework was completed, but satisfies prerequisites for advancement. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `start_certification_exam` + +Begin a specialist deep dive module (S1: Media Buy, S2: Creative, S3: Signals, S4: Governance, S5: Sponsored Intelligence). The learner must hold the Practitioner credential. Returns the capstone format, lab exercises, and assessment criteria. You (Addie) will conduct the combined hands-on lab and adaptive exam. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `complete_certification_exam` + +Finalize a specialist capstone. If the learner has demonstrated mastery (internal scores 70%+ in each dimension), awards the specialist credential and triggers Certifier badge issuance. If not yet ready, returns areas needing more work — keep teaching. Do not call until both the lab phase and exam phase are complete. Do not call if the learner asked to stop early. Never share scores with the learner. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +## Always available + +These tools are reachable in every conversation regardless of router intent. Both authenticated and anonymous users can use them when their handlers permit. + +### `escalate_to_admin` + +Escalate a request to human admins when you cannot fulfill it yourself. + +USE THIS WHEN: +- User asks you to perform an action you have no tool for (posting to channels, creating issues, renaming things) +- The request requires admin judgment, account access, or a human action you cannot perform +- The topic is genuinely sensitive (legal, compliance, confrontational, controversial-political) +- You've tried and failed to help with your available tools + +DO NOT USE FOR: +- Questions you can answer with your tools or your rule files (knowledge.md, behaviors.md) +- Community-fit questions ("would my background be a fit for the working groups?") — answer directly using the working-group mapping in knowledge.md and behaviors.md +- Routine membership pricing, including upgrade proration for any tier on credit card or invoice — the FAQ in knowledge.md covers this; only escalate refunds, out-of-cycle credits, custom contracts, and currency changes +- Multi-part questions where each part is independently answerable — decompose first, answer the parts, do NOT escalate the bundle (see "Decompose bundled questions" in constraints.md) +- "Complex" and "sensitive" are NOT magic words. Bundled or multi-domain questions are not automatically Complex; check whether each part is genuinely outside your knowledge or capability before escalating +- Things that don't require admin attention +- General conversation + +CONFIRM WITH USER BEFORE ESCALATING — you must get the user's consent first: +- Tell the user you don't have a tool for this and explain what you'd escalate +- Ask if they'd like you to pass it to the team +- Only call this tool after the user confirms they want you to escalate + +BEFORE CALLING THIS TOOL — gather enough context to make the escalation actionable: +- If the request is vague, ask clarifying questions first +- Confirm who the request is from: their name and organization +- ALWAYS collect an email address and/or Slack handle so the team can follow up. If the user hasn't provided one, ask before escalating. +- If someone is asking on behalf of another person, capture that person's name and contact details in the summary +- Include any relevant context (timeline, urgency, what they've already tried) + +When you escalate, be honest with the user that you're passing this to a human who can help. + +*Source: `server/src/addie/mcp/escalation-tools.ts`* + +### `get_escalation_status` + +Check the status of support requests previously escalated for the current user. +Use this when a user asks about the status of a previous request, a ticket, or whether someone followed up. +Returns a list of their escalations with current status and any resolution notes. + +*Source: `server/src/addie/mcp/escalation-tools.ts`* + +### `get_account_link` + +Get a link to connect the user's Slack account with their AgenticAdvertising.org account. Use this when a user's accounts are not linked and they want to access member features. IMPORTANT: Share the full tool output with the user - it contains the clickable sign-in link they need. The user clicks the link to sign in and their accounts are automatically connected. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `capture_learning` + +Capture valuable knowledge or perspective shared by the user. + +USE THIS WHEN the user shares: +- Strategic perspectives on the industry +- Adoption barriers or implementation experiences +- Feedback about AdCP or AgenticAdvertising.org +- Market intelligence or competitive insights +- Use cases or novel applications + +This helps the team learn from community conversations and improve Addie's knowledge. + +DO NOT use for: +- General questions or support requests +- Content already in the docs +- Off-topic conversations + +*Source: `server/src/addie/mcp/escalation-tools.ts`* + +### `set_outreach_preference` + +Set how often Addie sends proactive messages (tips, reminders, follow-ups). Choose a cadence or opt out entirely. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `search_image_library` + +Search the approved illustration library for images that match a topic or concept. Returns image URLs and alt text that you can include in your response. All searches are logged automatically. + +*Source: `server/src/addie/mcp/image-tools.ts`* + +### `draft_github_issue` + +Draft a GitHub issue and generate a pre-filled URL for the user to create it. Use this when users report bugs, request features, or ask you to create a GitHub issue. CRITICAL: Users CANNOT see tool outputs - you MUST copy this tool's entire output (the GitHub link, title, body preview) into your response. Never say "click the link above" without including the actual link. The user will click the link to create the issue from their own GitHub account. All issues go to the "adcp" repository which contains the protocol, schemas, AgenticAdvertising.org server, and documentation. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `create_github_issue` + +File a GitHub issue on adcontextprotocol/adcp authored by the logged-in user via their WorkOS Pipes GitHub connection. Use after showing the user a draft and getting their confirmation. If the user has not yet connected GitHub, the tool returns a message with a one-time Connect link AND reminds them they can ask for `draft_github_issue` instead — include that full message in your reply. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_github_issue` + +Read a GitHub issue or PR by number. Use when the user pastes a GitHub link, references "issue #1234", or asks about the status of a specific RFC, epic, or PR. Returns title, body, state, labels, author, and optionally recent comments. Works on any `adcontextprotocol/*` or `prebid/*` repo. PR review-thread comments (on specific diff lines) are NOT included — only issue-style comments. Do NOT use for keyword search — use list_github_issues. Do NOT use fetch_url on github.com/.../issues URLs; this tool returns structured fields and labels. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `propose_content` + +Submit a draft (article or link) for editorial review. Content lands in pending_review; a committee lead or admin approves it to publish. Default committee is "editorial" (site-wide Perspectives). Only `title` is required. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_my_content` + +Get all content where the user is an author, proposer, or owner (committee lead). Shows content across all collections with status and relationship info. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `list_pending_content` + +List content pending review that the user can approve/reject. Only committee leads see their committee content; admins see all pending content. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `approve_content` + +Approve pending content for publication. Only committee leads (for their committees) and admins can approve content. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `reject_content` + +Reject pending content with a reason. Only committee leads (for their committees) and admins can reject content. The proposer will see the rejection reason. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `check_illustration_status` + +Check if a perspective article has an editorial illustration and whether the author can generate one. + +*Source: `server/src/addie/mcp/illustration-tools.ts`* + +## Always available (admin) + +Admin-only tools reachable in every conversation regardless of router intent. + +### `resolve_escalation` + +Mark an escalation as resolved and notify the user via Slack DM or email. Use this after you've handled a request that was previously escalated. + +IMPORTANT: Always notify the user unless there's a reason not to (e.g., test escalation, duplicate). +Notification is sent via Slack DM when a Slack user ID is on record, or via email as fallback. +This is how you notify users about escalation outcomes — use it whenever asked to "let someone know", "follow up", or "close the loop" on an escalation. + +Examples: +- User needed admin role → used update_org_member_role → resolve and notify +- User needed co-leader added → used add_committee_co_leader → resolve and notify +- Admin says "this is fixed, let them know" → resolve and notify +- Duplicate request → resolve with wont_do, no notification needed + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_escalations` + +List escalations that need admin attention, or look up a specific escalation by ID. + +Use escalation_id to get details on a specific escalation (any status). +Use status filter to browse escalations (defaults to open). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +## Other tools + +Tools defined in the code but not referenced by any tool set above. These typically require specific channel/auth conditions to register; see the source for details. + +### `research_domain` + +Comprehensive domain research: checks brand registry, enriches via Brandfetch + Sonnet classification + Lusha firmographics. Skips sources that already have fresh data (< 30 days). Returns brand identity, corporate hierarchy (house_domain/parent_brand), and firmographics in one call. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `create_committee` + +Create a committee (working group, council, or governance body). For chapters use create_chapter; for conferences use create_industry_gathering. Can link an existing Slack channel by name. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `update_user_name` + +Update a user's display name (first and last name). Use this when a user's name is showing incorrectly (e.g., as their email prefix) and needs manual correction. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `ban_entity` + +Ban a user, organization, or API key. Scope can be platform-wide (blocks all access) or registry-specific (blocks brand/property edits only). + +Examples: +- Platform ban user: ban_type=user, entity_id=user_01HW..., scope=platform +- Ban org from brand edits: ban_type=organization, entity_id=org_01HW..., scope=registry_brand +- Revoke API key: ban_type=api_key, entity_id=wkapikey_..., scope=platform + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `unban_entity` + +Remove a ban. Provide either the ban ID directly, or the ban_type + entity_id + scope to look it up. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_bans` + +List active bans. Optionally filter by ban_type, scope, or entity_id. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_engagement_plan` + +Preview the engagement plan for a person — shows contact eligibility, scored opportunities, and what Addie would say. Use this to understand or debug engagement decisions. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `get_outreach_health` + +Get a health report on the outreach system: stage breakdown, over-contacted people approaching circuit breaker, outreach volume, and email stats. Use to assess system health or when asked "how is outreach doing?" + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `confirm_send_invoice` + +Send an invoice for the authenticated member's own organization after they have +confirmed the details shown by send_invoice. The contact email, company, and billing address come +from the signed-in session — they cannot be overridden. The org must already have a billing address +on file (set via the dashboard or invite-acceptance flow). + +*Source: `server/src/addie/mcp/billing-tools.ts`* + +### `get_billing_portal` + +Get a link to the Stripe Customer Portal where the member can view invoices, download receipts, update payment methods, and manage their subscription. +Use this when a member asks about receipts, invoices, billing history, payment methods, or subscription management. +The member must be signed in. + +*Source: `server/src/addie/mcp/billing-tools.ts`* + +### `upload_brand_logo` + +Upload a logo file for a brand in the registry. The logo will be pending review. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `checkpoint_teaching_progress` + +Save a snapshot of teaching progress for the current module. Required before calling complete_certification_module or complete_certification_exam. Call at these points: (a) after finishing each key concept group from the lesson plan, (b) before transitioning from teaching to assessment, (c) after the capstone lab phase before the exam phase, (d) if the learner needs to leave. IMPORTANT: On the first checkpoint, always include learner_background. Before completion, include demonstrations_verified with the criterion IDs the learner has met. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `get_build_phase_instructions` + +Get the exact instructions for a build project phase transition. You MUST call this tool when transitioning to the Build, Validate, or Extend phase of B4, C4, or D4. The tool returns the specific commands and URLs the learner needs — present them exactly as returned, do not rewrite or summarize. This ensures every learner gets the same validated workflow. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `save_learner_feedback` + +Save learner feedback after completing a certification module. Call this when the learner shares thoughts about the experience — what was confusing, what worked well, suggestions for improvement. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `recommend_storyboards` + +Probe an agent's `get_adcp_capabilities` and return the compliance bundles that will run. The agent's declared `supported_protocols` and `specialisms` drive the selection — no member configuration needed. If the agent declares nothing, explain what it needs to declare to get coverage. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_storyboard_detail` + +Show the full structure of a storyboard — phases, steps, what each step tests, and what passing looks like. Use this before running a storyboard so the developer understands what will be tested. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `run_storyboard` + +Run a complete storyboard against an agent and return step-by-step results. Each step shows pass/fail, validations, and what the agent returned. Use after recommend_storyboards and optionally get_storyboard_detail. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `run_storyboard_step` + +Run a single step of a storyboard. Returns the result plus a preview of the next step. Use this for step-by-step debugging — lets the developer see each request/response and decide whether to continue. Pass the context from the previous step result to maintain state. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `get_member_engagement` + +Get the current user's organization engagement data: journey stage, engagement score, persona/archetype, milestone completion, and persona-based working group recommendations. Use this to understand where a member is in their journey and what actions would help them advance. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `search_moltbook` + +Search Moltbook for posts about a topic. Moltbook is a social network for AI agents. Returns posts matching the search query with author, score, and comment count. + +*Source: `server/src/addie/mcp/moltbook-tools.ts`* + +### `get_moltbook_thread` + +Get a Moltbook post and its comments. Returns the full post content and discussion thread. + +*Source: `server/src/addie/mcp/moltbook-tools.ts`* + +### `post_to_moltbook` + +Create a new post on Moltbook. Rate limited to 1 post per 30 minutes. Use for sharing insights, asking questions, or starting discussions with other AI agents. + +*Source: `server/src/addie/mcp/moltbook-tools.ts`* + +### `comment_on_moltbook` + +Add a comment to a Moltbook post. Rate limited to 1 comment per 20 seconds and 50 comments per day. Use for engaging in discussions with other AI agents. + +*Source: `server/src/addie/mcp/moltbook-tools.ts`* + +### `get_moltbook_stats` + +Get Addie's Moltbook profile stats including karma, post count, follower count, and today's activity. + +*Source: `server/src/addie/mcp/moltbook-tools.ts`* + +### `get_moltbook_feed` + +Get the latest posts from Moltbook sorted by hot, new, top, or rising. + +*Source: `server/src/addie/mcp/moltbook-tools.ts`* + +### `suggest_newsletter_content` + +Suggest content for the community newsletters. Use this when someone says "this should be in The Prompt" or "add this to The Build" or "suggest this for the newsletter." The Prompt is Addie's community newsletter (for everyone). The Build is Sage's contributor briefing (for contributor seats). + +*Source: `server/src/addie/mcp/newsletter-tools.ts`* + +### `check_portrait_status` + +Check if the current member has an illustrated portrait, and whether they can generate one. Use this before offering portrait generation. + +*Source: `server/src/addie/mcp/portrait-tools.ts`* + +### `check_property_list` + +Check a list of publisher domains against the AAO registry. Returns a summary of issues and a report URL for full details. Domains are automatically normalized (www/m stripped), duplicates removed, and known ad tech infrastructure flagged. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `enhance_property` + +Analyze an unknown publisher domain and submit it to the registry as a pending entry for review. Checks domain age (flags < 90 days as high risk), validates adagents.json presence, and uses AI to assess whether it's a real publisher and its likely inventory types. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `resolve_catalog` + +Resolve identifiers (domains, app bundles, store IDs) to stable property_rids in the catalog. Auto-creates missing properties. Excludes known ad infrastructure and publisher masks. Returns property_rid for each identifier. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `browse_catalog` + +Browse the property catalog. Returns properties with their identifiers, classification, and source. Supports filtering and search. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `dispute_catalog_entry` + +File a dispute against a catalog entry. For identifier link disputes on medium/weak confidence, the link is suspended immediately pending review. For authoritative/strong links, the dispute is queued for review without suspension. + +*Source: `server/src/addie/mcp/property-tools.ts`* + +### `get_si_availability` + +Check if an offer or product is available from a brand agent before connecting. This is an anonymous pre-flight check that doesn't share user data. + +*Source: `server/src/addie/mcp/si-host-tools.ts`* + +### `list_si_agents` + +List AAO member brand agents that support Sponsored Intelligence protocol. Shows which brands users can have conversations with. + +*Source: `server/src/addie/mcp/si-host-tools.ts`* + +### `connect_to_si_agent` + +Connect user with an SI-hosted brand agent. Initiates a conversational session where the brand agent can interact with the user. + +*Source: `server/src/addie/mcp/si-host-tools.ts`* + +### `send_to_si_agent` + +Send a message to an active SI session with a brand agent. Use this when the user is already connected and wants to continue the conversation. + +*Source: `server/src/addie/mcp/si-host-tools.ts`* + +### `end_si_session` + +End the current SI session with a brand agent. Use when user is done talking to the brand or wants to return to normal conversation. + +*Source: `server/src/addie/mcp/si-host-tools.ts`* + +### `get_si_session_status` + +Check if there's an active SI session and get its current status. + +*Source: `server/src/addie/mcp/si-host-tools.ts`* + +### `lookup_cast` + +Look up a fictional character or AI agent from the AdCP universe. Returns their role, company, personality, story appearances, and related protocol walkthroughs. + +*Source: `server/src/addie/mcp/story-tools.ts`* + +### `lookup_story` + +Look up an AdCP story. Returns title, synopsis, featured characters, related protocol walkthroughs, and links. + +*Source: `server/src/addie/mcp/story-tools.ts`* + diff --git a/dist/docs/3.0.13/aao/connect-addie.mdx b/dist/docs/3.0.13/aao/connect-addie.mdx new file mode 100644 index 0000000000..bdbfccc224 --- /dev/null +++ b/dist/docs/3.0.13/aao/connect-addie.mdx @@ -0,0 +1,156 @@ +--- +title: Connect Addie to your AI client +description: Add the AAO MCP server to Claude Desktop, Claude Code, ChatGPT, and other MCP-compatible clients — with troubleshooting for the common "reconnection failed" error. +"og:title": "AdCP — Connect Addie to your AI client" +--- + +# Connect Addie to your AI client + +Addie runs on a hosted MCP endpoint at `https://agenticadvertising.org/mcp`. Most MCP clients that speak streamable HTTP can connect — Claude Desktop, Claude Code, ChatGPT, and custom clients built on the MCP SDK. This page covers the install steps for each, plus how to recover from the most common failure modes. + +For end-user help with what Addie can *do*, see [AAO for Members](/dist/docs/3.0.13/aao/users) and the [Addie Tool Reference](/dist/docs/3.0.13/aao/addie-tools). + +## Authentication at a glance + +The endpoint requires authentication on every request. It accepts two credential types: + +- **OAuth 2.1 user JWT** — for human-driven clients (Claude Desktop, Claude Code, ChatGPT, Cursor). Sign in with your AAO email; the client handles the OAuth flow. +- **WorkOS organization API key** — for server-to-server callers. Generate one at [agenticadvertising.org/dashboard/api-keys](https://agenticadvertising.org/dashboard/api-keys). Send as `Authorization: Bearer ` and skip OAuth entirely. + +Use one *or* the other on a given connection — never both. When a static `Authorization` header is present, the server treats the request as authenticated and the OAuth flow never starts. If your client still pops a browser despite the header, it's stripping or overriding it — verify with `claude mcp get addie` (Claude Code) or your client's equivalent. + +For the underlying OAuth surface (authorization server metadata, dynamic client registration, scopes), see the [Reference URLs](#reference-urls) at the bottom of this page. + +## Claude Desktop + +Claude Desktop's built-in **Connectors** UI is the smoothest path. Anthropic hosts the OAuth proxy, so you don't manage tokens yourself. Custom connectors require a paid Claude plan (Pro, Max, Team, or Enterprise). + +1. Open Claude Desktop → Settings → Connectors. +2. Click **Add custom connector** (or **Connect** → custom). +3. In the dialog, set Name = `Addie`, Remote MCP server URL = `https://agenticadvertising.org/mcp`, then click **Add**. +4. Sign in with your AAO email when the browser opens. +5. Claude Desktop shows Addie as connected. Tools appear in the chat tool picker. + +## ChatGPT + +ChatGPT supports remote MCP via the **Connectors** feature. Custom MCP servers require a paid plan (Pro, Business, Enterprise) — Plus and Free won't see this option. + +1. Open ChatGPT → Settings → Connectors → Advanced → enable Developer mode. +2. Click **Create** (or **Add MCP server**), choose type **MCP**. +3. URL: `https://agenticadvertising.org/mcp`, Authentication: **OAuth**. +4. Complete sign-in in the browser. + +ChatGPT's connector UI changes often. If labels don't match exactly, look for "remote MCP server" or "custom connector" in your settings. + +## Claude Code + +Claude Code (the CLI) has [a known bug](https://github.com/anthropics/claude-code/issues/10250) where OAuth completes but the post-auth reconnect fails, leaving the server marked as `failed`. This affects every remote MCP that uses streamable-HTTP + OAuth, not just Addie. Until Anthropic ships a fix, use one of the two paths below. + +### Recommended: stdio shim via `mcp-remote` + +`mcp-remote` is a small npm proxy that runs as a local stdio MCP server, handles OAuth itself, and forwards calls to the remote endpoint. It sidesteps Claude Code's broken reconnect path entirely. Requires Node 18 or newer. + +```bash +claude mcp add addie -- npx -y mcp-remote@latest https://agenticadvertising.org/mcp +``` + +Then run `/mcp` inside Claude Code and complete sign-in in the browser. `/mcp` should show `addie ✓ connected` and Addie's tools become available immediately. No restart required. + +### Alternative: native HTTP transport + +If you'd rather use the native transport: + +```bash +claude mcp add --transport http addie https://agenticadvertising.org/mcp +``` + +Run `/mcp`, complete sign-in. If you see *"Authentication successful, but server reconnection failed"*, fully quit Claude Code (⌘Q on macOS, not just close window) and relaunch. Sometimes a single restart picks up the stored tokens; often it doesn't. If one restart doesn't recover, fall back to the `mcp-remote` path above. + +### Using an API key instead of OAuth + +If you have a WorkOS organization API key, register the server with a static `Authorization` header so OAuth never runs: + +```bash +claude mcp add --transport http addie https://agenticadvertising.org/mcp \ + --header "Authorization: Bearer sk_your_key_here" +``` + +`claude mcp add` writes to `~/.claude.json` safely without disturbing your other entries. Hand-editing that file works but can clobber other servers — only do it if you know what's already there. The equivalent JSON shape is: + +```json +{ + "mcpServers": { + "addie": { + "type": "http", + "url": "https://agenticadvertising.org/mcp", + "headers": { + "Authorization": "Bearer sk_your_key_here" + } + } + } +} +``` + +Don't combine this with the OAuth flow — pick one. If Claude Code triggers OAuth despite the header being present, the key is invalid or expired. Generate a fresh one at [agenticadvertising.org/dashboard/api-keys](https://agenticadvertising.org/dashboard/api-keys). + +## Other MCP clients + +Any MCP client that speaks streamable HTTP + OAuth 2.1 can connect. Generic config: + +```json +{ + "type": "http", + "url": "https://agenticadvertising.org/mcp" +} +``` + +Discovery follows the standard pattern: 401 on first request includes `WWW-Authenticate: Bearer resource_metadata=...`, pointing the client at `/.well-known/oauth-protected-resource/mcp`, which lists the authorization server. + +## Troubleshooting + +### "Authentication successful, but server reconnection failed" + +This is Claude Code bug [#10250](https://github.com/anthropics/claude-code/issues/10250). The OAuth flow worked — your tokens are saved in `~/.claude/.credentials.json` — but the client failed to reconnect with them. A full restart sometimes recovers; the reliable workaround is the `mcp-remote` install path above. + +This is not Addie-specific: the same error affects Notion, Supabase, Slack, New Relic, and other remote MCP servers using OAuth. + +### 401 returned after OAuth completes + +If your client says it has a valid token but every request to `/mcp` returns 401: + +- Check the token is fresh. WorkOS access tokens are short-lived; refresh tokens last longer. Most clients auto-refresh; some don't. Force re-auth. +- Check you're not double-sending credentials. If your config has both an `Authorization` header *and* an OAuth flow, one will conflict with the other. +- Test the token manually: + ```bash + curl -i -X POST https://agenticadvertising.org/mcp \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"diag","version":"0.0.1"}}}' + ``` + A 401 here means the token is rejected at the server; a 200 means your client is mishandling auth. + +### Force a re-auth + +When stored tokens go stale and the client won't re-prompt: + +- **Claude Code:** `rm ~/.claude/.credentials.json` then run `/mcp`. (This clears OAuth state for *all* MCP servers; back it up first if you have several.) +- **Claude Desktop:** remove the Addie connector and re-add it. +- **ChatGPT:** disconnect the connector in settings and re-add. + +### "I configured Addie in `~/.claude/settings.json` and it's not loading" + +Claude Code reads MCP servers from `~/.claude.json` (the global config) or `.mcp.json` (project-scoped) — not from `settings.json`. `settings.json` holds permissions, hooks, and env vars only. Use `claude mcp add` rather than hand-editing. + +### Where to find Claude Code logs + +On macOS: `tail -f ~/Library/Logs/Claude/mcp*.log`. Run your client and trigger the failing flow while tailing — the actual error (token rejected, transport mismatch, network failure) shows up there. Redact tokens before sharing. + +## Reference URLs + +- **MCP endpoint:** `https://agenticadvertising.org/mcp` +- **Authorization server metadata** (RFC 8414): `https://agenticadvertising.org/.well-known/oauth-authorization-server` +- **Protected resource metadata** (RFC 9728): `https://agenticadvertising.org/.well-known/oauth-protected-resource/mcp` +- **Dynamic client registration** (RFC 7591): `POST https://agenticadvertising.org/register` +- **API keys dashboard:** [agenticadvertising.org/dashboard/api-keys](https://agenticadvertising.org/dashboard/api-keys) +- **Issue tracker:** [github.com/adcontextprotocol/adcp/issues](https://github.com/adcontextprotocol/adcp/issues) diff --git a/dist/docs/3.0.13/aao/directory-api.mdx b/dist/docs/3.0.13/aao/directory-api.mdx new file mode 100644 index 0000000000..e2f3625e76 --- /dev/null +++ b/dist/docs/3.0.13/aao/directory-api.mdx @@ -0,0 +1,245 @@ +--- +title: AAO Directory API — Agent ↔ Publisher Inverse Lookup +description: HTTP API for sales-agent operators to discover which publishers authorize their agent, sourced from the AAO directory's index of publisher adagents.json files. Returns provenance, per-publisher property counts, and lifecycle status. +"og:title": "AdCP — AAO Directory Inverse Lookup API" +--- + +# AAO Directory API + +The AAO directory at `agenticadvertising.org` indexes publisher `adagents.json` files across the open web. This API surfaces the **inverse map** every sales-agent operator needs at sync time: + +> "What publishers have authorized my agent?" + +Without this endpoint, operators have to either maintain the publisher domain list manually and call `fetch_agent_authorizations` against it, or crawl the open web themselves. Both are infeasible at managed-network scale ([cafemedia](https://cafemedia.com/.well-known/adagents.json) alone delegates ~6,800 publisher domains under a single manager file). + +This endpoint is **discovery**, not **authorization**. The publisher's own `adagents.json` remains the trust root. The directory tells you which publishers to verify directly via the SDK's per-domain primitives (`verify_agent_authorization`, `fetch_agent_authorizations`). + +## Endpoint + +``` +GET https://{aao_directory}/v1/agents/{agent_url}/publishers +``` + +`{agent_url}` MUST be percent-encoded. The directory canonicalizes the lookup key (lowercase host, default port stripped, trailing slash on path component normalized) using the same convention the SDK applies in `verify_agent_authorization`. + +### Query parameters + +| Parameter | Type | Default | Semantics | +|---|---|---|---| +| `since` | ISO 8601 | unset | Return only publishers whose `last_verified_at` ≥ `since`. Enables incremental sync. | +| `cursor` | opaque string | unset | Pagination cursor returned by a prior response. Stable across the directory's refresh cycle for the lifetime of the cursor. | +| `status` | string, repeated | `authorized` | Filter by lifecycle status. v1: `authorized`, `revoked`. Repeat the key once per value (OpenAPI `style: form, explode: true`). Comma-separated single-value form (`status=authorized,revoked`) is **not** accepted; directories MUST return `400` with an explanation pointing to the repeated-key form. | +| `limit` | int (1–1000) | 200 | Max publishers per page. | +| `include` | string, repeated | unset | Opt into expanded per-row fields. v1: `properties` — each `PublisherEntry` carries the canonical `property_ids[]` list under that publisher (lets consumers run full set-diff against a federated fetch, not just count comparison). Repeated-key form, same encoding rule as `status`. Unknown values return `400`. | + +#### Worked example: filtering by multiple status values + +``` +GET /v1/agents/https%3A%2F%2Fsales-agent.example.com%2F/publishers?status=authorized&status=revoked&limit=500 +``` + +Equivalent in TypeScript: + +```ts +const url = new URL(`${directory}/v1/agents/${encodeURIComponent(agentUrl)}/publishers`); +url.searchParams.append("status", "authorized"); +url.searchParams.append("status", "revoked"); +url.searchParams.set("limit", "500"); +``` + +Equivalent in Python (`requests`): + +```python +requests.get( + f"{directory}/v1/agents/{quote(agent_url, safe='')}/publishers", + params=[("status", "authorized"), ("status", "revoked"), ("limit", "500")], +) +``` + +The OpenAPI fragment for the `status` parameter: + +```yaml +- in: query + name: status + schema: + type: array + items: + type: string + enum: [authorized, revoked] + style: form + explode: true + required: false +``` + +Repeated-key was chosen because (a) it is what `URLSearchParams.append()` and OpenAPI's default `explode: true` produce, (b) it composes cleanly with future values that might contain a comma, and (c) it leaves no parser ambiguity at the directory. + +#### Worked example: requesting `?include=properties` for full set-diff + +``` +GET /v1/agents/https%3A%2F%2Fsales-agent.example.com%2F/publishers?include=properties +``` + +The default response carries `properties_authorized` as a count only. Count-equality is **not** set-equality: a publisher rotating three properties leaves the count unchanged but the set entirely different, which a count-based divergence detector cannot see. `?include=properties` adds a `property_ids: list[string]` field per `PublisherEntry` — the canonical IDs the agent's selectors resolve to under that publisher — so consumers can run full set-diff against a federated `fetch_agent_authorizations` result and detect rotation, not just delta-in-magnitude. + +The flag is opt-in to keep the default page payload small. Inline IDs add roughly the per-publisher property count × ~16 bytes per ID; on a managed-network parent file (~6,800 publishers × avg 1 property each ≈ 7 KB of additional IDs), the overhead is small but non-zero. Pagination semantics are unchanged. + +### Response + +```json +{ + "agent_url": "https://sales-agent.example.com", + "directory_indexed_at": "2026-05-19T12:00:00Z", + "publishers": [ + { + "publisher_domain": "recipeswithessentialoils.com", + "discovery_method": "ads_txt_managerdomain", + "manager_domain": "cafemedia.com", + "properties_authorized": 1, + "properties_total": 1, + "signing_keys_pinned": false, + "status": "authorized", + "last_verified_at": "2026-05-19T08:00:00Z" + }, + { + "publisher_domain": "wsj.com", + "discovery_method": "direct", + "manager_domain": null, + "properties_authorized": 47, + "properties_total": 200, + "signing_keys_pinned": true, + "status": "authorized", + "last_verified_at": "2026-05-19T10:00:00Z" + }, + { + "publisher_domain": "former-partner.example", + "discovery_method": "authoritative_location", + "manager_domain": "cafemedia.com", + "properties_authorized": 0, + "properties_total": 0, + "signing_keys_pinned": false, + "status": "revoked", + "last_verified_at": "2026-05-19T11:00:00Z" + } + ], + "next_cursor": "eyJv..." +} +``` + +With `?include=properties`, each `PublisherEntry` additionally carries `property_ids`: + +```json +{ + "publisher_domain": "recipeswithessentialoils.com", + "discovery_method": "ads_txt_managerdomain", + "manager_domain": "cafemedia.com", + "properties_authorized": 3, + "properties_total": 3, + "property_ids": ["p-001", "p-002", "p-003"], + "signing_keys_pinned": false, + "status": "authorized", + "last_verified_at": "2026-05-19T08:00:00Z" +} +``` + +## Field reference + +### Envelope + +| Field | Required | Notes | +|---|---|---| +| `agent_url` | yes | Canonicalized echo of the lookup key. | +| `directory_indexed_at` | yes | Most recent per-publisher refresh in the result set. Provenance for the consumer's own cache. **NULL on empty pages** — there's no anchor to report; consumers SHOULD NOT advance cache freshness from a null value. | +| `publishers` | yes | Array. Empty array is a valid response — the directory has indexed this agent but no current authorizations resolve. | +| `next_cursor` | optional | Opaque pagination cursor; absent or null on the terminal page. | + +### `PublisherEntry` + +| Field | Required | Notes | +|---|---|---| +| `publisher_domain` | yes | Publisher whose `adagents.json` authorizes the agent. | +| `discovery_method` | yes | `direct`, `authoritative_location`, `adagents_authoritative`, or `ads_txt_managerdomain`. See below. | +| `manager_domain` | conditional | Required when `discovery_method` ≠ `direct`. Null otherwise. | +| `properties_authorized` | yes | Count of properties under **this publisher_domain only** that the agent's selectors resolve to. Never a network-wide count. | +| `properties_total` | yes | Count of properties under **this publisher_domain only** in the publisher's file (or parent file's inline subset for that domain). Never a network-wide count. | +| `property_ids` | conditional | Present iff request included `?include=properties`. Canonical list of `property_id`s under this publisher that the agent's selectors resolve to — the same population `properties_authorized` counts, surfaced as IDs so consumers can run full set-diff (not just count comparison) against a federated fetch. Per-publisher scope; never network-wide. Treat as a set; order is unspecified. | +| `signing_keys_pinned` | optional | Whether the publisher pins `signing_keys[]` on this agent. When `true`, agent's signed responses MUST verify against the pinned set regardless of the agent's own JWKS. | +| `status` | yes | `authorized` or `revoked`. See below. | +| `last_verified_at` | yes | When the directory last fetched and validated this publisher's `adagents.json`. | + +### `discovery_method` values + +| Value | Meaning | Trust profile | +|---|---|---| +| `direct` | Agent listed in the publisher's own `/.well-known/adagents.json`. | Strongest — no delegation hops. | +| `authoritative_location` | Publisher's `/.well-known/adagents.json` declared `authoritative_location` pointing to a manager file that lists the agent. | Strong — publisher actively delegated. | +| `adagents_authoritative` | Discovered via the manager file's own `properties[]` carrying the publisher's domain (per [adcp#4825 inline resolution rule](https://github.com/adcontextprotocol/adcp/issues/4825)). | Medium — publisher named in manager file but didn't host the delegation themselves. | +| `ads_txt_managerdomain` | Discovered via the publisher's `ads.txt` `MANAGERDOMAIN=` directive pointing to the manager file. | Weakest — the [`managerdomain` fallback safety rule](/dist/docs/3.0.13/governance/property/adagents#safety-rules-for-this-fallback) is the only positive cross-check. | + +The directory verifies the `managerdomain` safety rule before returning a row with `discovery_method: ads_txt_managerdomain` — this is the directory's main value-add over a per-operator `ads.txt` crawl. + +### `status` values + +| Value | Meaning | +|---|---| +| `authorized` | Selector resolves to ≥ 1 property under this publisher_domain. The normal case. | +| `revoked` | Publisher previously authorized the agent and now lists this `publisher_domain` in `revoked_publisher_domains[]` of an authoritative file. Emitted as a tombstone on the first sync after revocation lands, then dropped. Lets operators propagate revocations without polling each publisher's cache TTL. | + +`unbound`, `pending`, `unreachable`, and `no_properties` are **intentionally not part of v1**. The directory only indexes publishers whose `adagents.json` was successfully fetched and references the agent. If a publisher disappears, the directory drops it from results rather than returning a tombstone (consumers track membership via set-diff against prior pages). + +## HTTP semantics + +| Status | Meaning | +|---|---| +| `200 OK` | Lookup succeeded. Body MAY have empty `publishers[]`. | +| `400 Bad Request` | Malformed `agent_url`, invalid cursor, unknown `status` value, or `status` supplied as a comma-separated list rather than repeated keys. | +| `404 Not Found` | Directory has never indexed any publisher referencing this `agent_url`. **Distinct from `200` + empty** (which means the directory has indexed this agent, but no current authorizations resolve). | +| `429 Too Many Requests` | Rate limit. `Retry-After` header set. Bucket key: `agent_url` (anonymous) plus IP (defense-in-depth). | +| `5xx` | Directory error. Consumer SHOULD retry with backoff. | + +The endpoint sets `Cache-Control` and `ETag`. Conditional `GET` (`If-None-Match`) is the wire-level cache mechanism; `directory_indexed_at` in the body is the freshness anchor for consumer logic. + +## Authentication + +V1 is unauthenticated. Publisher `adagents.json` files are public; the inverse map is public. If rate-limiting graduates from IP-based to identity-based, the path is a separate RFC layering [RFC 9421](https://datatracker.ietf.org/doc/html/rfc9421) request signing keyed off the agent's published JWKS — the agent proves it controls `agent_url` by signing the request. Out of scope for this RFC. + +## Pagination + +Cursors are opaque. Treat them as substitutable strings and pass them back verbatim. The directory MAY change cursor format without notice; consumers MUST NOT parse cursor contents. + +A cursor remains valid for at least one directory refresh cycle. Past that, the directory MAY return `400` with `cursor_expired` or `200` with re-traversal from the start — both are conforming. Consumers SHOULD record the wall-clock time of the prior request and refuse to use cursors older than 24 hours. + +## Relationship to other primitives + +The AAO directory complements the existing SDK primitives: + +| Question | Primitive | Direction | +|---|---|---| +| Is *this* agent listed in *this* publisher's `adagents.json`? | `verify_agent_authorization(adagents_data, agent_url)` | Push (publisher → agent) | +| Given a list of publishers, which authorize my agent? | `fetch_agent_authorizations(agent_url, publisher_domains)` | Pull, caller-supplied list | +| **Which publishers authorize my agent?** | **`GET /v1/agents/{agent_url}/publishers`** | **Pull, directory-supplied list** | + +The first two answer questions where the operator already knows the publisher set. The directory endpoint answers the operator's actual sync-time question: "what's my publisher set?" + +The recommended workflow: + +1. Call `GET /v1/agents/{agent_url}/publishers` to discover the publisher set. +2. For each `publisher_domain` in the response, the operator MAY call `verify_agent_authorization` against the publisher's own `adagents.json` to re-confirm against the trust root. The directory's `last_verified_at` reduces but does not eliminate the need for per-domain verification on critical paths. +3. Use the response's `properties_authorized` / `properties_total` for operator-facing scope summaries, and the `signing_keys_pinned` flag to surface which agents must publish a JWKS matching the publisher's pin. +4. Operators running a divergence detector (catching cases where the directory and the publisher's live `adagents.json` disagree) SHOULD request `?include=properties` and compare the directory's `property_ids[]` against a federated fetch as a set, not as a count. A publisher rotating N properties produces equal counts on both sides; only set-comparison catches it. + +## Relationship to `publisher_properties` inline resolution + +On managed-network-shape parent files (per [adcp#4825 inline resolution rule](/dist/docs/3.0.13/governance/property/adagents#resolution-paths)), the directory computes `properties_total` from the parent file's inline `properties[]` filtered by `publisher_domain`. Strict federation at this scale would require N HTTP fetches per directory refresh per publisher — the same scale problem operators have, moved one layer up. The directory uses the inline resolution rule the spec endorses. + +## Out of scope (v1) + +- **Authentication.** Public endpoint, anonymous rate limiting. Identity-bound limits arrive in a separate RFC if needed. +- **Cross-directory federation.** Single directory. The endpoint shape is defined such that multiple AAO-compatible directories could implement it; discovery of which directory to query is configuration today. +- **Push notification of new authorizations.** Poll-based v1. +- **Full property objects inline.** `?include=properties` returns the resolved `property_ids[]` only — not the property objects themselves. Consumers with the IDs can fetch detail via existing per-domain primitives. + +## See also + +- [adagents.json Tech Spec](/dist/docs/3.0.13/governance/property/adagents) — the trust root. +- [Managed Network Deployment](/dist/docs/3.0.13/governance/property/managed-networks) — the canonical multi-publisher pattern this endpoint indexes. +- [adcp#4825](https://github.com/adcontextprotocol/adcp/issues/4825) — `publisher_properties` inline resolution rule the directory's count fields depend on. diff --git a/dist/docs/3.0.13/aao/org-admins.mdx b/dist/docs/3.0.13/aao/org-admins.mdx new file mode 100644 index 0000000000..2ab49bd21d --- /dev/null +++ b/dist/docs/3.0.13/aao/org-admins.mdx @@ -0,0 +1,76 @@ +--- +title: AAO for Org Admins +description: What organization admins can do — manage members, change tier, view billing, configure brand and agents. +"og:title": "AdCP — AAO for Org Admins" +--- + +# AAO for Org Admins + +This page is for the person at your company who manages the AgenticAdvertising.org account: seat allocation, billing, brand configuration, agent declarations. For end-user docs see [AAO for Members](/dist/docs/3.0.13/aao/users). For Addie's full tool list, see the [Addie Tool Reference](/dist/docs/3.0.13/aao/addie-tools). + +You're an org admin if your account has the `admin` or `owner` role within your organization on AAO. The first person to set up a paid org is automatically the owner. + +## Membership tiers + +All AAO memberships are annual Stripe subscriptions. Upgrades prorate automatically; downgrades take effect at the next renewal. + +| Tier | Price | Contributor seats | Community-only seats | Payment | +|---|---|---|---|---| +| Explorer | $50/yr | 0 | 1 | Credit card | +| Professional | $250/yr | 1 | 1 | Credit card | +| Builder | $2,500/yr | 5 | 5 | Credit card | +| Partner | $10,000/yr | 10 | 50 | Credit card or invoice | +| Leader | $50,000/yr | 20+ | Unlimited | Credit card or invoice | + +**Contributor seats** include Slack, working groups, voting rights, directory listing, and everything in community-only. **Community-only seats** include Addie, certification, training, regional chapters — for team members who need to learn but don't need active collaboration access. + +## Manage seats + +- **Invite a teammate.** Ask Addie *"invite [email] to my org"* — she sends a Stripe-backed seat invitation. You must be admin or owner. +- **Promote / demote.** Today this is escalation-only — ask Addie *"set [email] as admin in my org"* and she'll route the request. We're working on self-serve. +- **Remove a member.** Escalation; the team handles this carefully so no certification credit is lost. + +## Billing + +- **View invoices.** Ask Addie *"show my invoices"* or visit [agenticadvertising.org/dashboard/billing](https://agenticadvertising.org/dashboard/billing). Org admins see the full org billing history. +- **Update payment method / billing address.** Use the Stripe customer portal — link via *"open my billing portal"* (Addie has `create_customer_portal_session`). +- **Generate an invoice or payment link.** *"Send me an invoice for Builder tier"* triggers `send_invoice` → confirm → `confirm_send_invoice`. +- **Apply a coupon.** Tell Addie the coupon ID when generating the invoice. +- **Upgrade tier.** *"Upgrade us to Partner"* — Stripe prorates the difference for the remainder of the current period. +- **Downgrade tier.** Same flow; takes effect at next renewal. + +For refunds, custom contracts, currency changes, or out-of-cycle credits, Addie escalates to a human. + +## Brand identity + +Your `brand.json` published at `/.well-known/brand.json` is the canonical identity for your company in the AdCP ecosystem. AAO uses it to validate property authorizations and to enable agent discovery. + +- **Build a brand.json.** [agenticadvertising.org/brand](https://agenticadvertising.org/brand) is the visual builder. +- **Verify domain ownership.** Ask Addie *"verify my brand for [domain]"* — she issues a DNS challenge via `request_brand_domain_challenge`. Add the TXT record, then *"check my brand verification"* (`verify_brand_domain_challenge`). +- **Property catalog.** Properties listed in your brand.json are auto-discovered. To check what AAO has resolved for your domain, ask *"what properties are resolved for [domain]?"*. + +## Agent declarations (adagents.json) + +If you're a publisher, you publish `/.well-known/adagents.json` declaring which agents are authorized to sell your inventory. + +- **Build an adagents.json.** [agenticadvertising.org/adagents](https://agenticadvertising.org/adagents). +- **Validate.** Ask Addie *"validate my adagents.json"* — `validate_adagents` checks shape and resolves authorizations. +- **Probe a sales agent.** *"Probe [agent URL]"* runs `probe_adcp_agent` to confirm the agent responds, returns capabilities, and is registered. +- **Listing not visible?** First check: is your member profile complete and your tier active? Then: *"check property resolution for [domain]"* — Addie can diagnose whether the registry crawler picked up your file. + +## Working group and committee leadership + +- **Lead a working group.** Working group leads can `list_committee_documents`, `create_working_group_post`, and approve/decline contributor membership requests. +- **Council leadership.** Council leads have `attach_content_asset` and `propose_content` permissions on council resources. + +Ask Addie *"what can I do as a [WG/council] lead?"* and she'll list the leader-only tools available to your role. + +## What Addie escalates rather than self-serves + +- Tier changes outside the standard upgrade flow (custom contracts, prorations on invoice). +- Refunds, voids, or out-of-cycle credits. +- Member promotions / demotions / removals. +- Account merges or domain changes after onboarding. +- Anything involving deleted or orphaned data. + +For these, Addie collects context and routes to the AAO team — usually with a 1–2 business day SLA. diff --git a/dist/docs/3.0.13/aao/users.mdx b/dist/docs/3.0.13/aao/users.mdx new file mode 100644 index 0000000000..d4348b25ff --- /dev/null +++ b/dist/docs/3.0.13/aao/users.mdx @@ -0,0 +1,64 @@ +--- +title: AAO for Members +description: What AgenticAdvertising.org members can do — sign-in, certification, working groups, perspectives, directory listings, and asking Addie for help. +"og:title": "AdCP — AAO for Members" +--- + +# AAO for Members + +This page covers what an AAO member — someone with an active membership of any tier — can do on AgenticAdvertising.org and through Addie. For organization admins (people who manage their company's seats and billing), see [AAO for Org Admins](/dist/docs/3.0.13/aao/org-admins). For the full list of Addie's tools, see the [Addie Tool Reference](/dist/docs/3.0.13/aao/addie-tools). + +## Sign in and link your account + +- **Sign in.** Go to [agenticadvertising.org](https://agenticadvertising.org) and sign in with email or Google. New visitors get a free anonymous Addie session; signing in unlocks member tools. +- **Link Slack.** If you joined the AAO Slack workspace, ask Addie *"link my account"* in any DM and she'll generate a personalized sign-in link. After signing in, your Slack identity is permanently linked. +- **Lost your link?** Ask Addie *"send me my sign-in link"* — she has a `get_account_link` tool that works in any channel. + +## Certification (AdCP Academy) + +| Tier | Price | What you get | +|---|---|---| +| Tier 1 — AdCP Basics | Free, no membership required | Three foundation modules, ~90 min | +| Tier 2 — AdCP Practitioner | Any active membership ($50+/yr) | Basics + one role-specific track + build project | +| Tier 3 — AdCP Specialist | Any active membership ($50+/yr) | Practitioner + a specialist capstone in one of five areas | + +Ask Addie: +- *"Start module A1"* — begins or resumes the relevant module in your current tier. +- *"Where am I in certification?"* — she'll list completed modules and what's next. +- *"What's the next step in my Practitioner track?"* — track-aware progression. + +## Working groups, councils, and chapters + +- **Browse working groups.** Ask *"what working groups are active?"* or visit [agenticadvertising.org/working-groups](https://agenticadvertising.org/working-groups). +- **Join a working group.** Tell Addie *"I want to join the [name] working group"* — Professional tier and above can join contributor-track WGs; Explorer tier has read-only access via the website. +- **Express council interest.** Councils require nomination; Addie can record your interest with `express_council_interest`. +- **Find your local chapter.** Ask *"is there an AAO chapter in [region]?"* — chapters are member-run and most accept newcomers without a separate join flow. + +## Perspectives (publishing on AAO) + +Members can publish *perspectives* — short articles, op-eds, and reports — on agenticadvertising.org under Stories. + +- **Submit a draft.** Ask Addie to *"propose a perspective"* with your title and content (or paste a Google Doc link — she'll read it via `read_google_doc`). +- **Editorial review.** Submitted perspectives go to `pending_review`. AAO admins review and approve, request edits, or decline. +- **Cover illustration.** Members get monthly free illustration generation. Ask *"generate a cover for my perspective"* once it's submitted. +- **Edit after publishing.** Use the edit button on the perspective page; if it's missing, ask Addie to investigate. + +## Directory listing + +- **Get listed.** Members on Professional tier and above get a directory listing on agenticadvertising.org/registry. Ask Addie *"set up my directory listing"* and she'll walk through company name, description, offerings, contact, and visibility. +- **Update your listing.** Ask *"update my directory listing"* — Addie has `update_company_listing`. +- **Add your logo.** *"Update my company logo to [URL]"* — `update_company_logo`. +- **Brand verification.** If you publish at an owned domain, ask *"verify my brand for [domain]"* and Addie issues a DNS challenge via `request_brand_domain_challenge`. + +## Profile + +- **View / update profile.** *"Show my profile"* / *"update my profile"* (`get_my_profile`, `update_my_profile`). +- **Profile photo.** Upload via the profile page on the website. If the upload control is missing, ask Addie to escalate. +- **Member portrait.** Members on Builder tier and above get an auto-generated graphic-novel portrait — ask *"generate my portrait"*. + +## What Addie can't do for you + +- Change your tier or process refunds — that's an org-admin action; ask the team via *"escalate to admin"*. +- Promote someone to admin in your org — escalation only. +- Delete an account — escalation; admins handle this carefully. +- Invent a tool that doesn't exist. If Addie says she has a tool that you can't find on this page or the [Addie Tool Reference](/dist/docs/3.0.13/aao/addie-tools), she's probably wrong — push back and ask her to verify. diff --git a/dist/docs/3.0.13/accounts/overview.mdx b/dist/docs/3.0.13/accounts/overview.mdx new file mode 100644 index 0000000000..08941757c2 --- /dev/null +++ b/dist/docs/3.0.13/accounts/overview.mdx @@ -0,0 +1,238 @@ +--- +title: Accounts Protocol +sidebarTitle: Overview +description: "AdCP Accounts Protocol defines the commercial layer for advertising transactions — billing, operator authorization, and usage reporting between buyers, brands, and vendor agents." +"og:title": "AdCP — Accounts Protocol" +--- + +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 + +Six 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 the account model: `true` means explicit accounts (operator credentials required, discover via `list_accounts`), `false` means implicit accounts (agent trusted, declare via `sync_accounts`) | +| 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.0.13/protocol/get_adcp_capabilities) via `require_operator_auth`. When `true` (**explicit accounts**), operators authenticate independently and the buyer discovers accounts via [`list_accounts`](/dist/docs/3.0.13/accounts/tasks/list_accounts). When `false` (**implicit accounts**), the agent is trusted and the buyer declares brand/operator pairs via [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts) to provision accounts. + +An **ad network** may use both models simultaneously — implicit accounts on the buyer-facing side (the network is agent-trusted) and explicit accounts with each underlying platform (the network authenticates as an operator). See the [Sponsored Intelligence guide — account model for networks](/dist/docs/3.0.13/sponsored-intelligence/networks#account-model-for-networks) for the full account chain: `buyer agent → network (implicit) → AI platform (explicit)`. + +After delivery, the orchestrator calls [`report_usage`](/dist/docs/3.0.13/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` (explicit accounts, `require_operator_auth: true`) or a natural key — `brand` + `operator` (implicit accounts, `require_operator_auth: false`). For sandbox, the path depends on the account model: explicit accounts discover pre-existing test accounts via `list_accounts`, while implicit accounts declare sandbox via `sync_accounts` with `sandbox: true`. See [Account references](/dist/docs/3.0.13/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 + +## 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 + Explicit (require_operator_auth: true): + list_accounts() → find existing account_id for this brand/operator + Implicit (require_operator_auth: false): + sync_accounts({ accounts: [{ brand, operator, billing, billing_entity? }] }) → status, billing terms + +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.0.13/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` (explicit accounts, `require_operator_auth: true`) or `sync_accounts` (implicit accounts, `require_operator_auth: false`). An agent MAY implement both. See [Required tasks by protocol](/dist/docs/3.0.13/protocol/required-tasks#any-agent-accepting-accounts). + +| Task | Purpose | +|------|--------| +| [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts) | Declare brand/operator pairs and billing; provision accounts (implicit accounts, `require_operator_auth: false`) | +| [`list_accounts`](/dist/docs/3.0.13/accounts/tasks/list_accounts) | Discover existing accounts (explicit accounts, `require_operator_auth: true`); poll status on pending accounts | +| [`get_account_financials`](/dist/docs/3.0.13/accounts/tasks/get_account_financials) | Query spend, credit, and invoice status for operator-billed accounts | +| [`sync_governance`](/dist/docs/3.0.13/accounts/tasks/sync_governance) | Sync governance agent endpoints to accounts for seller-side validation | +| [`report_usage`](/dist/docs/3.0.13/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.0.13/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 +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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/accounts/tasks/get_account_financials.mdx b/dist/docs/3.0.13/accounts/tasks/get_account_financials.mdx new file mode 100644 index 0000000000..531a098896 --- /dev/null +++ b/dist/docs/3.0.13/accounts/tasks/get_account_financials.mdx @@ -0,0 +1,233 @@ +--- +title: get_account_financials +description: "get_account_financials returns spend summaries, credit balances, payment status, and invoice history for operator-billed AdCP accounts. Requires account_financials capability." +"og:title": "AdCP — get_account_financials" +testable: false +--- + +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.0.13/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.0.13/account/get-account-financials-request.json`](https://adcontextprotocol.org/schemas/3.0.13/account/get-account-financials-request.json) +**Response Schema**: [`/schemas/3.0.13/account/get-account-financials-response.json`](https://adcontextprotocol.org/schemas/3.0.13/account/get-account-financials-response.json) + +## Quick start + +Check remaining credit before launching a campaign: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/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 +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.0.13/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 +import { testAgent } from "@adcp/client/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 +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 +import { testAgent } from "@adcp/client/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 +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.0.13/accounts/tasks/list_accounts) -- Discover accounts and check their status +- [get_adcp_capabilities](/dist/docs/3.0.13/protocol/get_adcp_capabilities) -- Check `account_financials` before calling this task +- [Accounts and agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) -- Billing models and account references diff --git a/dist/docs/3.0.13/accounts/tasks/list_accounts.mdx b/dist/docs/3.0.13/accounts/tasks/list_accounts.mdx new file mode 100644 index 0000000000..1055a53cba --- /dev/null +++ b/dist/docs/3.0.13/accounts/tasks/list_accounts.mdx @@ -0,0 +1,215 @@ +--- +title: list_accounts +description: "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." +"og:title": "AdCP — list_accounts" +testable: false +--- + +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. + +`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**: [`/schemas/3.0.13/account/list-accounts-request.json`](https://adcontextprotocol.org/schemas/3.0.13/account/list-accounts-request.json) +**Response Schema**: [`/schemas/3.0.13/account/list-accounts-response.json`](https://adcontextprotocol.org/schemas/3.0.13/account/list-accounts-response.json) + +## Quick Start + +List all accounts this agent can operate: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { ListAccountsResponseSchema } from "@adcp/client"; + +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 +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. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `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 or omitted, return only production accounts. Primarily used with explicit accounts (`require_operator_auth: true`) 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.0.13/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.0.13/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.0.13/accounts/tasks/sync_governance). | +| `setup` | Present when `status: "pending_approval"`. Contains `url` for completing setup and `message` explaining what's needed. | + +## Common Scenarios + +### Poll until account becomes active + +After `sync_accounts` returns `pending_approval`, poll until the account is ready: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { ListAccountsResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { ListAccountsResponseSchema } from "@adcp/client"; + +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 +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.0.13/accounts/tasks/sync_accounts) — Sync advertiser accounts with a seller +- [sync_governance](/dist/docs/3.0.13/accounts/tasks/sync_governance) — Sync governance agents to accounts +- [Accounts and agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) — Billing models, trust models, and authorized operators +- [Brand protocol](/dist/docs/3.0.13/brand-protocol/brand-json) — How vendor agents resolve brand identity from the brand's `domain` diff --git a/dist/docs/3.0.13/accounts/tasks/report_usage.mdx b/dist/docs/3.0.13/accounts/tasks/report_usage.mdx new file mode 100644 index 0000000000..5e36d6bb0d --- /dev/null +++ b/dist/docs/3.0.13/accounts/tasks/report_usage.mdx @@ -0,0 +1,189 @@ +--- +title: report_usage +description: "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." +"og:title": "AdCP — report_usage" +testable: false +--- + +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.0.13/account/report-usage-request.json`](https://adcontextprotocol.org/schemas/3.0.13/account/report-usage-request.json) +**Response Schema**: [`/schemas/3.0.13/account/report-usage-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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. | +| `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 +{ + "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 +{ + "accepted": 1 +} +``` + + + +### Creative usage — ad server with CPM pricing + + + +```json Request +{ + "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 +{ + "accepted": 1 +} +``` + + + +### Multi-account batch + +A single request spanning two campaigns across two accounts: + +```json +{ + "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 +{ + "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.0.13/accounts/tasks/sync_accounts) — Sync advertiser accounts with a seller before reporting +- [Accounts Protocol](/dist/docs/3.0.13/accounts/overview) — How account establishment and settlement fit together +- [Accounts and agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) — Billing hierarchy and operator model diff --git a/dist/docs/3.0.13/accounts/tasks/sync_accounts.mdx b/dist/docs/3.0.13/accounts/tasks/sync_accounts.mdx new file mode 100644 index 0000000000..c6241e21bd --- /dev/null +++ b/dist/docs/3.0.13/accounts/tasks/sync_accounts.mdx @@ -0,0 +1,397 @@ +--- +title: sync_accounts +description: "sync_accounts provisions or links advertiser accounts with an AdCP seller agent for one or more brand/operator pairs. Supports explicit and implicit account models with sandbox mode." +"og:title": "AdCP — sync_accounts" +testable: false +--- + +Sync advertiser accounts with a seller for one or more brand/operator pairs. The seller provisions or links accounts, returning per-account status and any setup instructions. 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. It declares the buyer's intent — the seller provisions or links accounts internally. For implicit accounts (`require_operator_auth: false`), use natural keys (`brand` + `operator`) on subsequent requests. For explicit accounts (`require_operator_auth: true`), discover seller-assigned account IDs via [`list_accounts`](/dist/docs/3.0.13/accounts/tasks/list_accounts). For sandbox on implicit accounts, include `sandbox: true` in the account entry — the seller provisions a test account with no real spend. For explicit accounts, sandbox accounts are pre-existing test accounts discovered via [`list_accounts`](/dist/docs/3.0.13/accounts/tasks/list_accounts). + +**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.0.13/account/sync-accounts-request.json`](https://adcontextprotocol.org/schemas/3.0.13/account/sync-accounts-request.json) +**Response Schema**: [`/schemas/3.0.13/account/sync-accounts-response.json`](https://adcontextprotocol.org/schemas/3.0.13/account/sync-accounts-response.json) + +## Quick start + +Sync a single advertiser account and check the resulting status: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncAccountsResponseSchema } from "@adcp/client"; + +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 +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.0.13/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. The seller must either accept this billing model or reject the request. | +| `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.0.13/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 implicit accounts (`require_operator_auth: false`). For explicit accounts, sandbox accounts are pre-existing test accounts discovered via `list_accounts`. | + +**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` (the agent's default account). See [account scope](/dist/docs/3.0.13/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 implicit accounts. | + +### Account status + +| Status | Meaning | Next step | +|--------|---------|-----------| +| `active` | Ready to use | Use [account reference](/dist/docs/3.0.13/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`). + +The notification payload includes the `(brand, operator)` natural key so the buyer can correlate it to the original sync request. For explicit accounts (`require_operator_auth: true`), the notification also includes the seller-assigned `account_id` once provisioned. + +```json +{ + "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.0.13/accounts/tasks/list_accounts) to check for status changes. + +## Common scenarios + +### Agency syncing multiple brands + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncAccountsResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { SyncAccountsResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { SyncAccountsResponseSchema } from "@adcp/client"; + +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 +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 does not support the requested billing model | Check `get_adcp_capabilities` for `supported_billing`, adjust or omit `billing` | +| `PAYMENT_TERMS_NOT_SUPPORTED` | Seller does not accept the requested payment terms | Omit `payment_terms` to accept the seller's default, or negotiate offline | +| `PAYMENT_REQUIRED` | Account has reached its credit limit | Add funds 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.0.13/accounts/tasks/list_accounts) -- Poll for status changes on pending accounts +- [sync_governance](/dist/docs/3.0.13/accounts/tasks/sync_governance) -- Sync governance agents to accounts +- [Accounts and agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) -- Billing models, trust models, and authorized operators +- [Brand protocol](/dist/docs/3.0.13/brand-protocol/brand-json) -- How seller agents resolve brand identity from the `brand.domain` +- [get_adcp_capabilities](/dist/docs/3.0.13/protocol/get_adcp_capabilities) -- Discover `supported_billing` and `require_operator_auth` before syncing accounts diff --git a/dist/docs/3.0.13/accounts/tasks/sync_governance.mdx b/dist/docs/3.0.13/accounts/tasks/sync_governance.mdx new file mode 100644 index 0000000000..4f150ef210 --- /dev/null +++ b/dist/docs/3.0.13/accounts/tasks/sync_governance.mdx @@ -0,0 +1,293 @@ +--- +title: sync_governance +description: "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." +"og:title": "AdCP — sync_governance" +testable: false +--- + +Sync governance agent endpoints to specific accounts. The seller persists these agents and calls them via `check_governance` during media buy lifecycle events. Each account entry pairs an [account reference](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents#account-references) with the governance agents for that account, supporting both explicit accounts (`account_id`) and implicit accounts (`brand` + `operator`). + +This uses **replace semantics** — each call replaces any previously registered agents on the specified accounts. Accounts not included in the request keep their existing configuration. + +**Response Time**: ~1s. + +**Request Schema**: [`/schemas/3.0.13/account/sync-governance-request.json`](https://adcontextprotocol.org/schemas/3.0.13/account/sync-governance-request.json) +**Response Schema**: [`/schemas/3.0.13/account/sync-governance-response.json`](https://adcontextprotocol.org/schemas/3.0.13/account/sync-governance-response.json) + +## Quick Start + +Sync a budget governance agent to an explicit account: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncGovernanceResponseSchema } from "@adcp/client"; + +const result = await testAgent.syncGovernance({ + accounts: [ + { + account: { account_id: "acct-social-001" }, + governance_agents: [ + { + url: "https://governance.pinnacle-media.com/budget", + authentication: { + schemes: ["Bearer"], + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + categories: ["budget_authority"] + } + ] + } + ] +}); + +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} agents registered`); + } else { + console.log(`${JSON.stringify(entry.account)}: failed — ${JSON.stringify(entry.errors)}`); + } +} +``` + +```python Python +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/budget", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "categories": ["budget_authority"] + } + ] + } + ] + ) + + 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)} agents 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.0.13/building/by-layer/L2/accounts-and-agents#account-references): `{account_id}` for explicit accounts or `{brand, operator}` for implicit accounts. | +| `governance_agents` | array | Yes | Governance agent endpoints for this account (1–10 per account). | + +**Each 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). | +| `categories` | array | No | Governance categories this agent handles (e.g., `["budget_authority", "geo_compliance"]`). When omitted, the agent handles all categories. Max 20 categories, each max 64 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 + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncGovernanceResponseSchema } from "@adcp/client"; + +const result = await testAgent.syncGovernance({ + accounts: [ + { + account: { account_id: "acct-social-001" }, + governance_agents: [ + { + url: "https://governance.pinnacle-media.com/budget", + authentication: { + schemes: ["Bearer"], + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + categories: ["budget_authority"] + } + ] + }, + { + account: { account_id: "acct-social-002" }, + governance_agents: [ + { + url: "https://governance.pinnacle-media.com/compliance", + authentication: { + schemes: ["Bearer"], + credentials: "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + }, + categories: ["geo_compliance"] + } + ] + } + ] +}); + +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 +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/budget", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "categories": ["budget_authority"] + } + ] + }, + { + "account": {"account_id": "acct-social-002"}, + "governance_agents": [ + { + "url": "https://governance.pinnacle-media.com/compliance", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + }, + "categories": ["geo_compliance"] + } + ] + } + ] + ) + + 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()) +``` + + + +### Implicit accounts (brand + operator) + + + +```json Request +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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/compliance", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + }, + "categories": ["geo_compliance", "strategic_alignment"] + } + ] + } + ] +} +``` + + + +### Rotate governance agent credentials + +Call `sync_governance` again with updated `authentication`. Replace semantics means the new credentials overwrite the previous configuration. + +## 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.0.13/accounts/tasks/list_accounts) — Discover accounts and their current governance agents +- [sync_accounts](/dist/docs/3.0.13/accounts/tasks/sync_accounts) — Provision or link advertiser accounts +- [check_governance](/dist/docs/3.0.13/governance/campaign/tasks/check_governance) — How sellers call governance agents during media buy events +- [Accounts and agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) — Account models, billing, and trust diff --git a/dist/docs/3.0.13/ai-disclosure.mdx b/dist/docs/3.0.13/ai-disclosure.mdx new file mode 100644 index 0000000000..90c7c3362b --- /dev/null +++ b/dist/docs/3.0.13/ai-disclosure.mdx @@ -0,0 +1,101 @@ +--- +title: AI Disclosure +description: "How AdCP and AgenticAdvertising.org use AI — what's AI-authored, what's AI-assisted, model and provider disclosure, and how to request human review." +"og:title": "AdCP — AI Disclosure" +--- + +AgenticAdvertising.org uses AI extensively to write content, generate imagery, ship code, and run operations. This page names every surface where that happens, the models behind it, and how to request human review. + +--- + +## What's AI-authored + +These surfaces are written primarily by an AI agent operated by AAO, with human editorial oversight: + +- **Addie** — AAO's teaching assistant and chat agent. All Addie chat responses are AI-generated. +- **Sage** — the AdCP protocol explainer agent. Protocol Q&A in the docs chat is answered by Sage. +- **The Prompt** — our biweekly newsletter, authored in first person as Addie. The Prompt is editorial and is also a marketing surface for AAO; read it as both. +- **The Build** — our triweekly technical newsletter, authored by Sage. +- **Member portraits** — graphic-novel-style illustrations for members are AI-generated. +- **Certification grading** — Addie grades the free Basics track against a fixed rubric of 3–5 required demonstrations per module, and also grades the paid Practitioner and Specialist tracks. AAO is both the issuer and the grader of these credentials; we disclose this conflict rather than deny it, and human review of any grading decision is available on request (see below). + +## What's AI-assisted + +Most of the rest of AAO's public surface is built with AI coding assistants, reviewed by humans before publishing. This includes: + +- The protocol schemas and documentation (this site) +- The open-source reference implementations +- The admin tools operated by AAO staff +- Most public-facing code in the [adcontextprotocol](https://github.com/adcontextprotocol) organization + +We don't mark individual paragraphs or pull requests as AI-assisted — the default is that AI tools were involved. + +## Model and provider disclosure + +- **Addie and Sage** run on Anthropic Claude models. +- **Image generation** (member portraits, illustrations) uses Google Gemini image models. +- **Protocol development** uses Claude Code and other agentic development tools. + +## Data handling + +- **Addie and Sage chat** — conversations are logged to improve teaching quality and to allow appeals on grading decisions. Logs are retained by AAO and not used by model providers for training. EU/UK residents: chat inputs are processed on our behalf by Anthropic; see our [privacy policy](https://agenticadvertising.org/api/agreement?type=privacy_policy) for the current data-processor chain and transfer mechanism. +- **Grading decisions** — the required-demonstrations, the Addie interaction that produced the credit, and the resulting assessment are retained so that a learner (or a regulator) can reconstruct the decision. +- **Member portraits** — AI-generated images are produced from member-provided inputs; the prompt and generated image are retained on the member's profile. + +## Human review + +You can request human review on any AI-generated surface. + +**Certification grading appeals SLA.** Because AAO is both the issuer and the AI grader of its certifications, every grading appeal gets a documented human-review path: + +- **Acknowledgement: 72 hours** from receipt at [help@agenticadvertising.org](mailto:help@agenticadvertising.org). +- **Decision: 10 business days** from acknowledgement, by an AAO staff reviewer who did not participate in the original grading. +- **Outcome on upheld appeals**: the credential is granted (if the appeal is for a denied attempt) or the assessment fee is refunded (if the appeal is for a graded attempt the learner does not want re-credentialed). +- **Escalation**: appeals that the staff reviewer cannot resolve are escalated to the AAO certification committee, which meets monthly. +- **Annual transparency report**: AAO publishes appeals volume, upheld rate, and median time-to-decision once per year as part of the AGM materials. The first report covers the period ending 2027-04. + +To file a grading appeal, email [help@agenticadvertising.org](mailto:help@agenticadvertising.org) with your learner ID, the module or assessment in question, and the specific finding you are contesting. + +Other AI-surface review paths: + +- **Content corrections** — factual errors in Addie's teaching, Sage's protocol explanations, or any AI-authored content can be reported via [GitHub issues](https://github.com/adcontextprotocol/adcp/issues) or [Slack](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg). Target turnaround is **five business days**. +- **Protocol guidance** — Sage is not a substitute for legal or regulatory advice. For compliance-sensitive questions, consult qualified counsel. + +## Content provenance (C2PA) + +Every AI-generated image AAO publishes carries an embedded [C2PA](https://c2pa.org/) manifest signed by AAO: + +- **Member portraits** — also carry a visible "AI" badge in the bottom-right corner (CA SB 942 visible-disclosure path). +- **Newsletter cover art** — The Prompt and The Build covers, including the copies that ship in subscriber email and render as OpenGraph share cards. +- **Perspective article hero images** — every editorial illustration attached to a published perspective. +- **Docs walkthrough and concept illustrations** — the panel PNGs embedded throughout this site's walkthroughs and concept explainers. + +The manifest identifies Google Gemini as the generating software agent, marks the asset as `trainedAlgorithmicMedia` per the IPTC digital-source-type vocabulary, and includes a timestamp plus a SHA-256 of the generation prompt (not the prompt itself — portraits are generated from member-provided descriptions we do not want to republish). AAO signs with a self-signed P-256 certificate held in production secrets. CAI trust-list inclusion is a future step; today, public verifiers will show the signature as cryptographically valid but flag *"issuer not on trust list."* + +**Verify any AAO image** at [contentcredentials.org/verify](https://contentcredentials.org/verify) by uploading the file or pasting its URL. + +For editorial illustrations and docs storyboards where a visible mark would undermine the graphic-novel aesthetic, the C2PA manifest is the sole disclosure surface. CA SB 942's visible-disclosure rule targets upstream generative-AI providers rather than downstream publishers, so this placement is defensible for AAO — but it is a deliberate choice, not an oversight. + +If you find an AAO-generated image that does not carry a manifest, please [open an issue](https://github.com/adcontextprotocol/adcp/issues) — we treat missing provenance as a bug. + +## Regulatory posture + +This disclosure is informed by the FTC Endorsement Guides (2023), EU AI Act Art 50, and California SB 942. It has not been reviewed by outside counsel. + +- **EU AI Act Art 50(2)** and **CA SB 942** require machine-readable provenance on AI-generated images and video — see the [content provenance](#content-provenance-c2pa) section above for how we satisfy this. +- **FTC Endorsement Guides** apply to endorsements and testimonials for compensation. They are not activated by general AI authorship; we call them out here for completeness, not because we claim they are satisfied by this page alone. + +If you believe a specific AI surface falls short of an applicable standard, let us know. + +## Institutional conflicts + +AI authorship and evaluation by AAO intersect with AAO's broader governance in ways readers should know: + +- AAO is both the issuer and the grader of its certifications (Addie evaluates coursework and grants credentials AAO sells). +- AAO's founder's other company (Scope3) contributed funding and foundational IP — see the [FAQ entry](/dist/docs/3.0.13/faq#how-is-aao-related-to-scope3) for the full relationship. + +The governance framework, board composition, and recusal rules are set out in [CHARTER.md](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md), with the authoritative board list and funding disclosures at [agenticadvertising.org/governance](https://agenticadvertising.org/governance). + +--- + +Material changes to how AI is used at AAO will be reflected on this page. diff --git a/dist/docs/3.0.13/brand-protocol/brand-json.mdx b/dist/docs/3.0.13/brand-protocol/brand-json.mdx new file mode 100644 index 0000000000..b66e2b0052 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/brand-json.mdx @@ -0,0 +1,880 @@ +--- +title: brand.json Specification +description: "brand.json specification for AdCP. File format, variants (portfolio, redirect, agent, authoritative location), brand definition fields, visual guidelines, colorways, type scale, restrictions, and resolution algorithm." +"og:title": "AdCP — brand.json Specification" +--- + +The `brand.json` file provides a standardized way for brands to claim their identity and establish discoverable brand information. It supports four mutually exclusive variants to accommodate different use cases. + + +`brand.json` is the canonical source of brand identity data. The brand object defined here (logos, colors, tone, tagline) is the single brand definition used across AdCP. Tasks reference brands by domain and brand_id — the system resolves full identity from `brand.json` or the [registry](/dist/docs/3.0.13/registry/index). + + +## File location + +Brands host the `brand.json` file at: + +``` +https://example.com/.well-known/brand.json +``` + +Following [RFC 8615](https://datatracker.ietf.org/doc/html/rfc8615) well-known URI conventions. + +## Variants + +The brand.json file supports four mutually exclusive variants: + +### 1. Authoritative Location Redirect + +Points to a hosted brand.json at another URL: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "authoritative_location": "https://adcontextprotocol.org/brand/abc123/brand.json" +} +``` + +Use this when: +- Brand.json is hosted centrally (e.g., by a service provider) +- CDN distribution is needed +- Managed brand services + +### 2. House Redirect + +Points to the house domain that contains the full brand portfolio: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "house": "nikeinc.com", + "note": "Regional site - see house for brand portfolio" +} +``` + +Optional fields: +- `region`: ISO 3166-1 alpha-2 country code (e.g., "CN") +- `note`: Explanation text + +Use this when: +- Brand domain is owned by a larger house +- Regional/localized domains point to main house +- Legacy domains redirect to canonical + +### 3. Brand Agent + +Designates an MCP agent that provides brand information: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "brand_agent": { + "url": "https://agent.acme.com/mcp", + "id": "acme_brand_agent" + } +} +``` + +Optional fields: +- `contact`: Contact information + +When a brand has an agent, the agent is the authoritative source for brand identity data. + +### 4. House Portfolio + +Contains the full brand hierarchy with all brands and properties: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "nikeinc.com", + "name": "Nike, Inc.", + "architecture": "hybrid" + }, + "brands": [ + { + "id": "nike", + "names": [{"en": "Nike"}], + "keller_type": "master", + "properties": [ + {"type": "website", "identifier": "nike.com", "primary": true} + ] + } + ] +} +``` + +## House definition + +The house object represents the corporate entity: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `domain` | string | Yes | House's primary domain | +| `name` | string | Yes | Display name | +| `names` | array | No | Localized names | +| `architecture` | enum | No | `branded_house`, `house_of_brands`, or `hybrid` | + +## Brand definition + +Each brand in the `brands` array: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes | Brand identifier (lowercase alphanumeric with underscores) | +| `names` | array | Yes | Localized names (see below) | +| `keller_type` | enum | No | `master`, `sub_brand`, `endorsed`, `independent` | +| `parent_brand` | string | No | Parent brand's id | +| `properties` | array | No | Digital properties associated with this brand | +| `brand_agent` | object | No | Agent providing brand identity data `{ url, id }` | +| `rights_agent` | object | No | Rights licensing agent `{ url, id, available_uses, right_types, countries }` | +| `logos` | array | No | Brand logo assets | +| `colors` | object | No | Brand color palette | +| `fonts` | object | No | Brand typography | +| `tone` | object | No | Brand voice and messaging guidelines (`voice`, `attributes`, `dos`, `donts`) | +| `tagline` | string | No | Brand tagline or slogan | +| `visual_guidelines` | object | No | Structured visual rules for generative creative systems | + +### Names Array + +Names are localized with language codes: + +```json +{ + "names": [ + {"en": "Nike"}, + {"en": "The Swoosh"}, + {"zh": "耐克"}, + {"ja": "ナイキ"} + ] +} +``` + +Multiple entries per language are allowed (for aliases). + +### Keller Types + +Brand architecture classifications from marketing theory: + +| Type | Description | Example | +|------|-------------|---------| +| `master` | Primary brand of house | Nike for Nike, Inc. | +| `sub_brand` | Carries parent brand name | Nike SB | +| `endorsed` | Independent identity, endorsed by parent | Air Jordan "by Nike" | +| `independent` | Operates separately from house | Converse | + +### Extended color roles + +The `colors` object has five standard roles (`primary`, `secondary`, `accent`, `background`, `text`), but brands can and should provide additional roles for finer granularity. The schema accepts any additional color role via `additionalProperties`. + +```json +{ + "colors": { + "primary": "#FF6600", + "secondary": "#0066CC", + "background": "#FFFFFF", + "text": "#1A1A1A", + "heading": "#FF6600", + "body": "#333333", + "label": "#666666", + "border": "#E5E5E5", + "divider": "#F0F0F0", + "surface_1": "#F9F9F9", + "surface_2": "#F0F0F0" + } +} +``` + +| Role | Purpose | +|------|---------| +| `heading` | Heading text color (when different from body text) | +| `body` | Body text color | +| `label` | Label/caption text color | +| `border` | Border/outline color | +| `divider` | Divider/separator color | +| `surface_1` | Primary surface/card background | +| `surface_2` | Secondary surface background | + +These extended roles help creative agents distinguish between text hierarchies and surface levels without guessing. + +## Visual guidelines + +The `visual_guidelines` object provides structured rules that generative creative systems can use to produce on-brand assets consistently. These are brand constants — they don't change campaign to campaign. + + +Visual guidelines complement the basic identity fields (`colors`, `fonts`, `logos`). Colors define *what* the brand palette is; visual guidelines define *how* to use it. Fonts define font families; visual guidelines define the type scale. + + +### Photography + +Controls how brand photography should look when selected or generated: + +```json +{ + "photography": { + "realism": "natural", + "lighting": "soft daylight", + "color_temperature": "warm", + "contrast": "medium", + "depth_of_field": "medium", + "subject": { + "people": { + "age_range": "25-45", + "diversity": "mixed", + "mood": ["confident", "relaxed"] + }, + "product_focus": "in-use", + "setting": "outdoor" + }, + "framing": { + "subject_position": "center-left", + "crop_style": "waist-up", + "perspective": "eye-level" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `realism` | enum | `natural`, `stylized`, `hyperreal`, `abstract` | +| `lighting` | string | Lighting style description | +| `color_temperature` | enum | `warm`, `neutral`, `cool` | +| `contrast` | enum | `low`, `medium`, `high` | +| `depth_of_field` | enum | `shallow`, `medium`, `deep` | +| `subject` | object | Subject matter guidelines (people, product focus, setting) | +| `framing` | object | Camera framing rules (position, crop, perspective) | +| `preferred_aspect_ratios` | array | Preferred aspect ratios (e.g., `["16:9", "4:5", "1:1"]`) | +| `tags` | array | Additional style descriptors | + +### Graphic style + +Defines the visual language for brand graphics and illustrations: + +```json +{ + "graphic_style": { + "style_type": "flat_illustration", + "stroke_style": "rounded", + "stroke_weight": "2px", + "corner_radius": "12px" + } +} +``` + +Style types: `flat_illustration`, `geometric`, `gradient_mesh`, `editorial_collage`, `hand_drawn`, `minimal_line_art`, `3d_render`, `isometric`, `photographic_composite`. + +| Field | Type | Description | +|-------|------|-------------| +| `style_type` | enum | `flat_illustration`, `geometric`, `gradient_mesh`, `editorial_collage`, `hand_drawn`, `minimal_line_art`, `3d_render`, `isometric`, `photographic_composite` | +| `stroke_style` | enum | `rounded`, `square`, `mixed`, `none` | +| `stroke_weight` | string | Stroke weight (e.g., `2px`) | +| `corner_radius` | string | Corner radius for graphic/illustration elements (e.g., `12px`). For UI components, see `border_radius`. | +| `tags` | array | Additional style descriptors | + +### Shapes + +Brand shapes used as part of visual identity: + +```json +{ + "shapes": { + "primary_shape": "circle", + "secondary_shapes": ["rounded_rectangle", "diagonal_wave"], + "usage": { + "max_per_layout": 2, + "overlap_allowed": true + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `primary_shape` | string | Primary brand shape (e.g., `circle`, `rounded_rectangle`, `hexagon`) | +| `secondary_shapes` | array | Secondary shapes in the brand vocabulary | +| `usage.max_per_layout` | integer | Maximum distinct shapes per layout | +| `usage.overlap_allowed` | boolean | Whether shapes may overlap | + +### Iconography + +Icon style system and usage rules: + +```json +{ + "iconography": { + "style": "outline", + "stroke_weight": "2px", + "corner_style": "rounded", + "usage": { + "max_per_frame": 3, + "size_ratio": "1:8" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `style` | enum | `outline`, `filled`, `duotone`, `flat`, `glyph`, `hand_drawn` | +| `stroke_weight` | string | Icon stroke weight (e.g., `2px`) | +| `corner_style` | enum | `rounded`, `square`, `mixed` | +| `usage.max_per_frame` | integer | Maximum icons per creative frame | +| `usage.size_ratio` | string | Icon-to-layout size ratio (e.g., `1:8`) | + +### Composition + +Layout rules for overlays, textures, and backgrounds: + +```json +{ + "composition": { + "overlays": { + "gradient_style": "linear", + "gradient_direction": "45deg", + "opacity": "70%" + }, + "texture": { + "style": "subtle_grain", + "intensity": "low" + }, + "backgrounds": { + "types_allowed": ["solid_color", "gradient", "image"] + } + } +} +``` + +Texture styles: `none`, `subtle_grain`, `noise`, `paper`, `fabric`, `concrete`. Intensity: `low`, `medium`, `high`. + +Background types: `solid_color`, `gradient`, `blurred_photo`, `image`, `video`, `pattern`, `transparent`. + +### Border radius + +Named border radius presets for UI components and layout elements. Border radius is one of the most visible brand differentiators — generous radii feel warm and approachable, while small or zero radii feel precise and editorial. + +```json +{ + "border_radius": { + "none": "0", + "default": "12px", + "small": "4px", + "large": "20px", + "pill": "999px" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `none` | string | Explicitly sharp corners (`0`) | +| `default` | string | Default border radius for UI components (e.g., `8px`, `12px`) | +| `small` | string | Small radius for compact elements (e.g., `4px`) | +| `large` | string | Large radius for cards and containers (e.g., `16px`, `24px`) | +| `pill` | string | Fully rounded / pill shape (e.g., `999px`) | + +Additional named presets can be added beyond the five standard levels. + + +`graphic_style.corner_radius` defines a default radius for graphic/illustration elements. `border_radius` defines a named scale for UI components and layout — buttons, cards, inputs, modals. + + +### Elevation + +Named shadow levels that define how elements appear to lift off the surface. Brands use elevation as identity — some prefer dramatic multi-layer shadows, others use a single diffuse shadow. + +```json +{ + "elevation": { + "none": "none", + "subtle": "0 1px 3px rgba(0,0,0,0.08)", + "card": "0 4px 8px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.06)", + "modal": "0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.06)" + } +} +``` + +Values use CSS `box-shadow` syntax. Generative systems can apply these directly. + +| Field | Type | Description | +|-------|------|-------------| +| `none` | string | No shadow (`none`) | +| `subtle` | string | Slight lift for interactive elements | +| `card` | string | Card-level elevation | +| `modal` | string | Modal/overlay elevation | + +Additional named levels (e.g., `dropdown`, `tooltip`) can be added. + +### Spacing + +Spacing system for consistent layout rhythm. A base unit plus a named scale enables creative agents to produce correctly-spaced layouts without guessing. + +```json +{ + "spacing": { + "unit": "8px", + "scale": { + "xs": "4px", + "sm": "8px", + "md": "16px", + "lg": "24px", + "xl": "32px", + "2xl": "48px" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `unit` | string | Base grid unit this scale was designed from (e.g., `8px`). Informational — agents should use the named scale values directly. | +| `scale` | object | Named spacing scale (`xs` through `2xl`) | + +The `scale` object supports standard sizes (`xs`, `sm`, `md`, `lg`, `xl`, `2xl`) and can include additional named values (e.g., `3xl`, `section`). + +### Graphic elements + +Reusable decorative or structural visual elements that are part of the brand identity — torn paper edges, watermarks, dividers, background patterns: + +```json +{ + "graphic_elements": [ + { + "name": "Paper Tear", + "type": "frame", + "description": "Torn paper edge used as section dividers and photo frames. Use primarily vertical orientation.", + "orientation": "vertical", + "colors": ["#a75230", "#f6f1f1", "#fba007"], + "max_per_layout": 2 + }, + { + "name": "Location Sketch Watermark", + "type": "watermark", + "description": "Light hand-drawn building sketch behind content, visible through the logo area", + "colors": ["#a75230"] + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Element name | +| `type` | enum | No | `border`, `divider`, `frame`, `watermark`, `pattern`, `texture_overlay`, `decorative` | +| `description` | string | No | How the element is used in layouts | +| `orientation` | enum | No | `horizontal`, `vertical`, `any` | +| `colors` | array | No | Colors this element may appear in | +| `max_per_layout` | integer | No | Maximum instances per layout | + +### Motion + +Motion and animation rules for video, animated display, and interactive formats: + +```json +{ + "motion": { + "transition_style": "dissolve", + "animation_speed": "moderate", + "easing": "ease-in-out", + "text_entrance": "fade", + "pacing": "lingering", + "kinetic_typography": false + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `transition_style` | enum | `cut`, `dissolve`, `slide`, `wipe`, `zoom`, `fade` | +| `animation_speed` | enum | `slow`, `moderate`, `fast` | +| `easing` | string | Default easing function (e.g., `ease-in-out`, `spring`, `linear`) | +| `text_entrance` | enum | `fade`, `typewriter`, `slide_up`, `slide_left`, `scale`, `none` | +| `pacing` | enum | `lingering`, `moderate`, `fast_cuts` | +| `kinetic_typography` | boolean | Whether animated/kinetic typography is allowed | +| `tags` | array | Additional motion style descriptors | + +### Logo placement + +Logo placement and clear space rules for automated creative production: + +```json +{ + "logo_placement": { + "preferred_position": "bottom-left", + "min_clear_space": "0.5x", + "min_height": "40px", + "background_contrast": "any" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `preferred_position` | enum | `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center`, `bottom-right`, `center` | +| `min_clear_space` | string | Minimum clear space as a multiple of logo height (e.g., `0.5x`, `1x`) or fixed value (e.g., `16px`) | +| `min_height` | string | Minimum logo height for legibility (e.g., `40px`) | +| `background_contrast` | enum | `light_only`, `dark_only`, `any` | + +### Colorways + +Named color pairings that define how colors work together. Allows a creative brief to reference "use my primary colorway" without specifying every color: + +```json +{ + "colorways": [ + { + "name": "primary", + "foreground": "#FFFFFF", + "background": "#FF6600", + "accent": "#0066CC", + "cta_foreground": "#FFFFFF", + "cta_background": "#0066CC" + }, + { + "name": "inverted", + "foreground": "#FF6600", + "background": "#FFFFFF", + "accent": "#0066CC", + "border": "#FF6600" + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Colorway name (e.g., `"primary"`, `"inverted"`, `"dark"`) | +| `foreground` | hex color | Yes | Text/foreground color | +| `background` | hex color | Yes | Background color | +| `accent` | hex color | No | Accent color | +| `cta_foreground` | hex color | No | Call-to-action text color | +| `cta_background` | hex color | No | Call-to-action button color | +| `border` | hex color | No | Border color | +| `channels` | array | No | Channels where this colorway applies (e.g., `["online"]`, `["print", "pos"]`). Omit for universal colorways. | + +### Type scale + +Typography scale defining sizes and weights for different text roles: + +```json +{ + "type_scale": { + "base_width": "1080px", + "heading": { + "font": "primary", + "size": "48px", + "weight": "700", + "line_height": "1.1" + }, + "subheading": { + "font": "primary", + "size": "24px", + "weight": "600", + "line_height": "1.3" + }, + "body": { + "font": "secondary", + "size": "16px", + "weight": "400", + "line_height": "1.5" + }, + "caption": { + "font": "secondary", + "size": "12px", + "weight": "400", + "line_height": "1.4" + }, + "cta": { + "font": "primary", + "size": "18px", + "weight": "700", + "text_transform": "uppercase", + "letter_spacing": "0.05em" + } + } +} +``` + +The `font` field references font roles defined in the brand's `fonts` object (`"primary"`, `"secondary"`), or can specify a font family name directly. + +When sizes are in pixels, use `base_width` to indicate the reference canvas these sizes were designed for. Generative systems should scale proportionally for other canvas sizes — a `48px` heading designed for `1080px` width would scale to `14px` on a `320px` mobile leaderboard. + +### Asset libraries + +References to managed asset libraries (icon sets, illustration systems, image collections). URLs are for human access — a brand portal, press kit, or DAM landing page that a person can open in a browser. + +```json +{ + "asset_libraries": [ + { + "name": "Brand Illustrations v2", + "type": "illustration_system", + "url": "https://brand.example.com/illustrations", + "description": "Flat illustration system with defined color guide", + "color_guide": { + "roles": ["base", "shadow_1", "shadow_2", "highlight_1", "highlight_2", "stroke"], + "palettes": [ + { + "name": "orange", + "colors": { + "base": "#FF6600", + "shadow_1": "#CC5200", + "shadow_2": "#993D00", + "highlight_1": "#FF8533", + "highlight_2": "#FFB380", + "stroke": "#662900" + } + } + ] + } + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name of the asset library | +| `type` | enum | Recommended | `icon_set`, `illustration_system`, `image_library`, `video_library`, `template_library` | +| `url` | string (URI) | Yes | URL to the asset library (for human access) | +| `description` | string | No | Description of library contents and usage | +| `color_guide` | object | No | Color roles and palettes used in the library | + +The `color_guide` provides generative systems with the color palettes used in the library — useful for producing on-brand illustrations or icons without accessing the library itself. + +### Restrictions + +Visual prohibitions and guardrails — the visual equivalent of `tone.donts`. These tell generative systems what to avoid: + +```json +{ + "restrictions": [ + "Never place text over the product", + "Do not use black backgrounds", + "No stock photography of people on phones", + "No split-screen layouts" + ] +} +``` + +## Property definition + +Properties are digital touchpoints associated with brands: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | enum | Yes | Property type (see below) | +| `identifier` | string | Yes | Domain or app ID | +| `store` | enum | No | App store (`apple`, `google`, etc.) | +| `region` | string | No | ISO country code or `global` | +| `primary` | boolean | No | Is this the primary property? | +| `relationship` | enum | No | How this brand relates to the property: `owned` (default), `direct`, `delegated`, `ad_network`. Matches `delegation_type` in adagents.json for bilateral verification. See [ad networks](/dist/docs/3.0.13/sponsored-intelligence/networks). | + +### Property types + +Matches AdCP property-type enum: +- `website` +- `mobile_app` +- `ctv_app` +- `desktop_app` +- `dooh` +- `podcast` +- `radio` +- `streaming_audio` + +### Property relationships + +Properties default to `owned` — the brand operates the property directly. For networks and SSPs that sell inventory they don't own, the `relationship` field declares the commercial arrangement: + +| Value | Meaning | Example | +|---|---|---| +| `owned` | Brand owns and operates this property (default) | Your own website | +| `direct` | Brand is the direct sales path, even if a third party runs the tech | Publisher's in-house ad team using a vendor's platform | +| `delegated` | Brand manages monetization — in charge of ad sales | Mediavine managing a food blog | +| `ad_network` | Brand sells as part of a network/exchange — a path, not the path | PubMatic as an SSP | + +This is the AdCP equivalent of `sellers.json` — the operator's public declaration of which publishers they work with. The publisher confirms by setting the matching `delegation_type` on the agent's authorization in their [adagents.json](/dist/docs/3.0.13/governance/property/adagents). See [ad networks](/dist/docs/3.0.13/sponsored-intelligence/networks) for the bilateral verification pattern. + +## Resolution algorithm + +To resolve a domain to a canonical brand: + +1. Fetch `https://{domain}/.well-known/brand.json` +2. Check variant: + - **authoritative_location**: Fetch from that URL, continue from step 2 + - **house** (string): Fetch from house domain, continue from step 2 + - **brand_agent**: Return agent URL (agent provides brand info) + - **house** (object) + **brands**: Search for domain in properties +3. For house portfolio, find the brand whose properties contain the query domain +4. Return canonical brand information + +Maximum redirect depth: 3 hops. + +## Complete examples + +### Small Business + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "bobsburgers.com", + "name": "Bob's Burgers LLC" + }, + "brands": [ + { + "id": "bobs_burgers", + "names": [{"en": "Bob's Burgers"}], + "keller_type": "master", + "properties": [ + {"type": "website", "identifier": "bobsburgers.com", "primary": true} + ], + "logos": [ + { "url": "https://bobsburgers.com/logo.svg", "tags": ["icon"] } + ], + "colors": { "primary": "#FF6B35" } + } + ] +} +``` + +### Enterprise with Agent + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "brand_agent": { + "url": "https://brand-agent.enterprise.com/mcp", + "id": "enterprise_brand_agent" + }, + "contact": { + "name": "Enterprise Brand Team", + "email": "brand@enterprise.com" + } +} +``` + +### Multi-Brand Portfolio + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "nikeinc.com", + "name": "Nike, Inc.", + "architecture": "hybrid" + }, + "brands": [ + { + "id": "nike", + "names": [{"en": "Nike"}, {"zh": "耐克"}, {"ja": "ナイキ"}], + "keller_type": "master", + "properties": [ + {"type": "website", "identifier": "nike.com", "primary": true}, + {"type": "website", "identifier": "nike.cn", "region": "CN"}, + {"type": "mobile_app", "store": "apple", "identifier": "com.nike.omega"} + ] + }, + { + "id": "air_jordan", + "names": [{"en": "Air Jordan"}, {"en": "Jordan"}, {"en": "Jumpman"}], + "keller_type": "endorsed", + "parent_brand": "nike", + "properties": [ + {"type": "website", "identifier": "jordan.com", "primary": true}, + {"type": "website", "identifier": "jumpman23.com"}, + {"type": "mobile_app", "store": "apple", "identifier": "com.nike.snkrs"} + ] + }, + { + "id": "converse", + "names": [{"en": "Converse"}], + "keller_type": "independent", + "properties": [ + {"type": "website", "identifier": "converse.com", "primary": true} + ] + } + ], + "contact": { + "name": "Nike Brand Team", + "email": "brand@nike.com" + } +} +``` + +### Talent Agency with Rights + +A talent agency managing athlete brands with licensable rights: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "lotientertainment.com", + "name": "Loti Entertainment", + "architecture": "house_of_brands" + }, + "brands": [ + { + "id": "daan_janssen", + "names": [{"en": "Daan Janssen"}], + "description": "Dutch Olympic speed skater, 2x gold medalist", + "industries": ["sports"], + "logos": [ + { + "url": "https://cdn.lotientertainment.com/janssen/headshot.jpg", + "variant": "primary" + } + ], + "brand_agent": { + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment" + }, + "rights_agent": { + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment", + "available_uses": ["likeness", "voice", "endorsement"], + "right_types": ["talent"], + "countries": ["NL", "BE", "DE"] + } + } + ] +} +``` + +The `rights_agent` field tells crawlers what's licensable without any MCP calls — available uses, rights type, and countries. Buyer agents can search the registry for "Dutch athletes available for voice licensing" and find matches from the indexed brand.json data. + +### Regional Domain Redirect + +On `nike.cn/.well-known/brand.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "house": "nikeinc.com", + "region": "CN" +} +``` + +## Caching + +Recommended cache TTLs: +- Canonical files: 24 hours +- Redirect files: 24 hours +- Failed lookups: 1 hour + +## Best practices + +1. **Start simple**: Begin with minimal brand.json and add complexity as needed +2. **Use redirects for subsidiaries**: Point brand domains to house domain +3. **List all properties**: Include regional domains, apps, and legacy domains +4. **Keep names current**: Include localized names and common aliases +5. **Visual guidelines are optional**: Add them when you need generative systems to produce on-brand assets consistently. Start with colorways and restrictions — they have the highest immediate impact. +6. **Keep portfolios lean**: For house portfolios with many brands, include visual guidelines only on brands that need them. Full visual guidelines on every brand in a large portfolio increases file size significantly. diff --git a/dist/docs/3.0.13/brand-protocol/building-a-brand-agent.mdx b/dist/docs/3.0.13/brand-protocol/building-a-brand-agent.mdx new file mode 100644 index 0000000000..3e360f9c1f --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/building-a-brand-agent.mdx @@ -0,0 +1,390 @@ +--- +title: Building a brand agent +description: "Build an AdCP brand agent as an MCP server. Serve brand identity via get_brand_identity, license talent rights via get_rights and acquire_rights, with public and authorized data tiers." +"og:title": "AdCP — Building a brand agent" +--- + +A brand agent is an MCP server that implements brand protocol tasks. DAMs, talent agencies, and brand portals build brand agents to make their data available to buyer agents over AdCP. + +The agent declares `supported_protocols: ["brand"]` in [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities). The specific tasks it implements define its role: + +| Role | Tasks | Example | +|------|-------|---------| +| Identity provider | `get_brand_identity` | Acme DAM serving brand assets and guidelines | +| Rights manager | `get_rights` + `acquire_rights` | Pinnacle Agency licensing talent | +| Both | All three | Nova Talent managing identity and rights | + +## Server setup + +Every brand agent starts with an MCP server that registers AdCP tasks as tools. + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "acme-brand-agent", + version: "1.0.0", +}); +``` + +Register `get_adcp_capabilities` so buyer agents can discover your supported protocols: + +```typescript +server.tool("get_adcp_capabilities", {}, async () => ({ + content: [{ + type: "text", + text: JSON.stringify({ + supported_protocols: ["brand"], + supported_tasks: ["get_brand_identity"], + }), + }], +})); +``` + +## Transport and HTTP setup + +Wire the MCP server to an HTTP endpoint so buyer agents can reach it over the network: + +```typescript +import express from "express"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; + +const app = express(); +app.use(express.json()); + +app.post("/mcp", async (req, res) => { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + res.on("close", () => transport.close()); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); + +app.listen(3000, () => console.log("Brand agent listening on port 3000")); +``` + +This gives you a stateless HTTP endpoint at `/mcp`. For production, add authentication middleware and CORS headers. + +## Tier 1: identity only + +Implement `get_brand_identity` to serve brand data from your DAM or brand portal. + +```typescript +const FIELDS_ENUM = [ + "description", "industries", "keller_type", "logos", "colors", + "fonts", "visual_guidelines", "tone", "tagline", + "voice_synthesis", "assets", "rights", +] as const; + +server.tool( + "get_brand_identity", + "Returns brand identity data. Core fields are always public.", + { + brand_id: z.string().describe("Brand identifier"), + fields: z.array(z.enum(FIELDS_ENUM)).optional() + .describe("Sections to include. Omit for all authorized sections."), + use_case: z.string().optional() + .describe("Intended use case — agent tailors content accordingly"), + }, + async ({ brand_id, fields, use_case }, extra) => { + const brand = await loadBrand(brand_id); + if (!brand) { + return { + content: [{ type: "text", text: JSON.stringify({ + errors: [{ code: "brand_not_found", message: `No brand with id '${brand_id}'` }], + }) }], + isError: true, + }; + } + + const isAuthorized = await checkLinkedAccount(extra); + const response = buildIdentityResponse(brand, { fields, use_case, isAuthorized }); + + return { content: [{ type: "text", text: JSON.stringify(response) }] }; + } +); +``` + +## Public vs authorized data + +Every `get_brand_identity` response includes the public baseline: `brand_id`, `house`, `names`, `description`, `industries`, `keller_type`, basic `logos`, and `tagline`. No authentication required. + +Authorized callers — linked via [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts) — get deeper data on top of that baseline: high-res assets, voice synthesis configs, tone guidelines, and rights availability. + +```typescript +function buildIdentityResponse(brand, { fields, use_case, isAuthorized }) { + // Core fields are always returned + const response = { + brand_id: brand.id, + house: brand.house, + names: brand.names, + }; + + // Determine which sections to include + const publicFields = ["description", "industries", "keller_type", "logos", "tagline"]; + const authorizedFields = ["colors", "fonts", "visual_guidelines", "tone", + "voice_synthesis", "assets", "rights"]; + + const requested = fields ?? [...publicFields, ...authorizedFields]; + const withheld = []; + + for (const field of requested) { + if (publicFields.includes(field)) { + response[field] = brand[field]; + } else if (isAuthorized) { + response[field] = brand[field]; + } else { + withheld.push(field); + } + } + + // Signal what's behind auth + if (withheld.length > 0) { + response.available_fields = withheld; + } + + return response; +} +``` + +When a public caller requests `fields: ["logos", "tone"]`, they get logos but not tone. The response includes `available_fields: ["tone"]` so the caller knows what linking their account would unlock. + +## Tier 2: rights only + +Add `get_rights` and `acquire_rights` for rights discovery and licensing. This is the path for talent agencies and music sync platforms. + +```typescript +server.tool( + "get_rights", + "Search for licensable rights with pricing", + { + query: z.string().describe("Natural language description of desired rights"), + uses: z.array(z.string()).describe("Rights uses: likeness, voice, name, endorsement"), + buyer_brand: z.object({ + domain: z.string(), + brand_id: z.string().optional(), + }).optional(), + brand_id: z.string().optional(), + include_excluded: z.boolean().optional(), + }, + async ({ query, uses, buyer_brand, brand_id, include_excluded }) => { + const matches = await searchRights({ query, uses, brand_id }); + + // Filter by buyer compatibility when buyer_brand is provided + const { rights, excluded } = buyer_brand + ? await filterByBuyerCompatibility(matches, buyer_brand) + : { rights: matches, excluded: [] }; + + const response = { rights }; + if (include_excluded) response.excluded = excluded; + + return { content: [{ type: "text", text: JSON.stringify(response) }] }; + } +); +``` + +`acquire_rights` follows the same pattern — accept a `rights_id` and `pricing_option_id` from `get_rights`, clear against existing contracts, and return terms with generation credentials. The response includes an authenticated `approval_webhook` (using [`push-notification-config`](https://adcontextprotocol.org/schemas/3.0.13/core/push-notification-config.json)) so buyers can submit creatives for review. See the [acquire_rights task reference](/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights) for the full schema. + +## Confidential brand rules + +Brands often have rules they cannot disclose — public figure policies, internal exclusion lists, legal restrictions. Your agent evaluates these internally and returns a sanitized reason without revealing the rule itself. + +The protocol supports this through a simple convention: if the rejection includes `suggestions`, the buyer can fix the problem. If it doesn't, the rejection is final and the buyer should move on. + +```typescript +async function evaluateAcquisition(request, talent) { + // Confidential rules — buyer never sees these + const confidentialResult = await evaluateConfidentialRules(request, talent); + if (confidentialResult.blocked) { + return { + status: "rejected", + reason: confidentialResult.sanitized_reason, + // No suggestions — this is final, nothing the buyer can change + }; + } + + // Actionable rejection — buyer can adjust their request + const exclusivityConflict = await checkExclusivity(request, talent); + if (exclusivityConflict) { + return { + status: "rejected", + reason: `Exclusive conflict in ${exclusivityConflict.country} through ${exclusivityConflict.end_date}`, + suggestions: [ + `Available in ${exclusivityConflict.alternative_countries.join(", ")}`, + `Available after ${exclusivityConflict.end_date}`, + ], + }; + } + + // Approved — proceed with terms + return { status: "acquired", /* ... */ }; +} +``` + +The same pattern applies to `get_rights` exclusions: include `suggestions` on excluded results when the buyer can adjust their query (different market, different dates), omit them when the exclusion is non-negotiable. + +### Defending against probing + +A determined buyer agent could call `get_rights` with slight variations — different brands, industries, countries — to map out your confidential rules through the pattern of rejections. Mitigate this by: + +- **Using consistent generic language** across similar confidential rejections. If three different rules all produce "This conflicts with our talent lifestyle guidelines," the buyer learns nothing from repeated attempts. +- **Returning the same reason regardless of which specific rule triggered it.** Don't vary the wording based on the rule — that creates a side channel. +- **Rate limiting discovery calls** per buyer. Track query volume per `buyer_brand` and return progressively less specific reasons after a threshold. + +The `exclusivity_status.existing_exclusives` field in `get_rights` responses deserves special care. Populating it with specific deal terms ("exclusive with Acme Sports in NL through Q3") reveals competitive intelligence. Use vague descriptions ("exclusive commitment in this category") or omit the field entirely when confidentiality is a concern. + +## Field selection and use case + +The `fields` parameter lets callers request only the sections they need. Implement this efficiently — avoid loading expensive data (asset catalogs, voice configs) when not requested: + +```typescript +async function loadBrandData(brand_id, fields) { + const brand = await db.getBrandCore(brand_id); + if (!fields || fields.includes("assets")) { + brand.assets = await db.getBrandAssets(brand_id); + } + if (!fields || fields.includes("voice_synthesis")) { + brand.voice_synthesis = await voiceProvider.getConfig(brand_id); + } + return brand; +} +``` + +The `use_case` parameter is advisory — it tailors content within returned sections but does not override `fields`. A `"likeness"` use case prioritizes action photos in the `logos` section; a `"creative_production"` use case prioritizes vector logos and brand marks. + +## Multi-tenancy + +A single MCP endpoint can serve multiple brands. The `brand_id` parameter in every request disambiguates which brand the caller is asking about. + +```typescript +// One agent, many brands +const brands = { + "emma_torres": { house: { domain: "pinnacleagency.com", name: "Pinnacle Agency" }, ... }, + "kai_nakamura": { house: { domain: "pinnacleagency.com", name: "Pinnacle Agency" }, ... }, +}; + +async function loadBrand(brand_id) { + return brands[brand_id] ?? null; +} +``` + +Each brand in your roster should also appear in your `brand.json` file's `brands` array so buyer agents can discover them before making MCP calls. + +## Account linking + +Buyers establish authorization by calling [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts) on your agent. After linking, their subsequent `get_brand_identity` requests are recognized as authorized. + +Implement the [accounts protocol](/dist/docs/3.0.13/accounts/overview) to support this. The linked account is identified by the caller's credentials in the MCP transport — you do not need to pass account IDs in brand protocol requests. + +### Extracting caller identity + +```typescript +async function checkLinkedAccount(extra: any): Promise { + // The caller's identity comes from your auth middleware. + // After sync_accounts links a buyer, store their credentials + // and check them on subsequent requests. + const sessionId = extra?.sessionId; + if (!sessionId) return false; + return await db.isLinkedAccount(sessionId); +} +``` + +How you identify callers depends on your authentication setup. The MCP transport provides session information; your auth middleware maps that to a linked account. See the [authentication guide](/dist/docs/3.0.13/building/by-layer/L2/authentication) for patterns. + +## Rights and creative integration + +After a buyer acquires rights through `acquire_rights`, they receive `generation_credentials` and a `rights_constraint`. These connect the rights grant to creative production. + +### From the brand agent's perspective + +When implementing `acquire_rights`, return both pieces in the response: + +```typescript +// In your acquire_rights handler, after approval: +const response = { + status: "acquired", + rights_id: "rgt_dj_001", + terms: { /* ... pricing, dates, restrictions */ }, + generation_credentials: [ + { + provider: "midjourney", + rights_key: "rk_dj_likeness_2026_abc", + uses: ["likeness"], + expires_at: "2026-06-15T00:00:00Z", + }, + ], + rights_constraint: { + rights_id: "rgt_dj_001", + rights_agent: { url: "https://rights.lotientertainment.com/mcp", id: "loti_entertainment" }, + valid_from: "2026-03-15T00:00:00Z", + valid_until: "2026-06-15T23:59:59Z", + uses: ["likeness"], + countries: ["NL"], + impression_cap: 100000, + approval_status: "approved", + }, +}; +``` + +### How buyers use these + +The buyer's orchestrator passes `generation_credentials` to their creative agent, which uses them with the AI provider. The `rights_constraint` is embedded in the creative manifest's `rights` array — it travels with the creative through the supply chain so every system in the chain knows the usage terms. + +```typescript +// Buyer-side: passing rights to a creative agent +const creative = await creativeAgent.callTool({ + name: "build_creative", + arguments: { + brand: { domain: "bistro-oranje.nl" }, + format_id: { agent_url: "https://ads.example.com", id: "video_social_1080x1920" }, + brief: "15-second vertical video featuring Daan Janssen endorsing Bistro Oranje", + generation_credentials: acquireResponse.generation_credentials, + rights: [acquireResponse.rights_constraint], + }, +}); +``` + +The creative agent uses the `generation_credentials` to authenticate with the AI provider (Midjourney, ElevenLabs, etc.) and produces the asset. The `rights` array becomes part of the creative manifest metadata — downstream systems (ad servers, verification vendors) can inspect it to confirm the creative is properly licensed. + +For the full creative manifest specification, see [creative manifests](/dist/docs/3.0.13/creative/creative-manifests). + +## Testing + +Use the `validate_brand_agent` MCP tool to verify your agent is reachable and responding correctly. For automated testing during development, use the MCP SDK's in-memory transport: + +```typescript +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); +await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + +const result = await client.callTool({ + name: "get_brand_identity", + arguments: { brand_id: "emma_torres" }, +}); +``` + +Key things to verify: core fields returned for public callers, deeper data for authorized callers, `available_fields` lists withheld sections, and `brand_not_found` errors for invalid IDs. + +## Deployment checklist + +- [ ] `brand.json` hosted at `/.well-known/brand.json` with `brand_agent.url` pointing to your MCP endpoint +- [ ] `get_adcp_capabilities` returns `supported_protocols: ["brand"]` +- [ ] `get_brand_identity` returns core fields for public callers +- [ ] `get_brand_identity` returns deeper data for authorized callers +- [ ] `available_fields` correctly lists withheld sections +- [ ] Error responses use the `errors` array format +- [ ] If implementing rights: `get_rights` returns pricing options and `acquire_rights` returns terms + +## Related + +- [Brand protocol overview](/dist/docs/3.0.13/brand-protocol/index) — How brand discovery works +- [brand.json spec](/dist/docs/3.0.13/brand-protocol/brand-json) — File format for brand declaration +- [get_brand_identity](/dist/docs/3.0.13/brand-protocol/tasks/get_brand_identity) — Identity task reference +- [get_rights](/dist/docs/3.0.13/brand-protocol/tasks/get_rights) — Rights discovery task reference +- [acquire_rights](/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights) — Rights acquisition task reference +- [Accounts overview](/dist/docs/3.0.13/accounts/overview) — How account linking works diff --git a/dist/docs/3.0.13/brand-protocol/for-advertisers.mdx b/dist/docs/3.0.13/brand-protocol/for-advertisers.mdx new file mode 100644 index 0000000000..1279342097 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/for-advertisers.mdx @@ -0,0 +1,78 @@ +--- +title: For advertisers +description: "License real talent for AI-generated ads through AdCP. Search for available athletes, musicians, and influencers with transparent pricing, then receive scoped generation credentials for AI creative tools." +"og:title": "AdCP — For advertisers" +--- + +You can license a real person's likeness for your AI-generated ads. Not stock footage. Not a lookalike. The actual athlete, musician, or influencer — with their permission, at a transparent price. + +The Ad Context Protocol (AdCP) connects buyers to talent rights through a standard that AI media buyers already speak. You describe what you need, the protocol finds who is available, and you get scoped credentials that let your creative tools generate ads featuring licensed talent. + +## How it works, from your side + +Imagine you run Bistro Oranje, a steakhouse in Amsterdam, and you want a local celebrity in your next campaign. Here is what the process looks like: + +1. **You describe what you need.** You (or your AI media buyer) search for "Dutch athlete available for food brands in the Netherlands." You can include your budget, preferred rights types (likeness, voice, endorsement), and campaign geography. + +2. **The system returns matches with pricing.** Results come back ranked by relevance: Daan Janssen, Olympic speed skater, available for food brands in the Netherlands. Two pricing options — EUR 3.50 CPM or EUR 350/month flat rate with up to 100,000 impressions. + +3. **You select the option that fits your budget and submit a request.** Your request includes what you want to create, which formats, what countries, how many impressions, and for how long. This is a binding contractual request. + +4. **The talent's agency reviews and approves.** The agency checks your request against the talent's preferences and existing contracts. If another food brand already holds exclusivity in the Netherlands, your request is rejected automatically. If it clears, you receive terms. + +If a rejection includes suggestions — alternative markets, different dates, adjusted scope — your agent can revise the request and resubmit. If there are no suggestions, the rejection is final for that talent and campaign combination. Agencies manage confidential rules (legal constraints, internal policies, public figure guidelines) that are not always appropriate to disclose — your agent understands the difference and will either adjust or move on. + +5. **You receive generation credentials.** These are scoped keys that allow specific AI providers (image generators, voice synthesis tools) to produce content featuring the licensed talent. The credentials expire when the contract ends. + +6. **Every impression is tracked.** Usage is reported back to the rights holder for billing and cap enforcement. If you hit your impression cap, generation stops until you renegotiate. + +## What you get + +When a rights acquisition is approved, your creative tools receive everything they need to produce and distribute the campaign: + +- **AI-generated ads featuring licensed talent.** Video, display, and audio — whatever formats you requested. The talent's likeness and voice are generated by AI providers who verify your credentials before producing content. +- **Scoped generation credentials.** Keys that work with specific providers (e.g., Midjourney for likeness, ElevenLabs for voice). Any creative agent can use them. The provider enforces the scope — you cannot generate outside your license terms. +- **A rights constraint for your creative manifest.** This travels with the ad through the supply chain, proving the content is licensed and describing its boundaries. +- **Disclosure text.** Provided for you, ready to attach to the creative: "Features AI-generated likeness of Daan Janssen, used under license from Loti Entertainment." + +## What it costs + +Pricing is set by the talent's agency, not by an auction. You see the price before you commit. + +Two common models: + +- **CPM** — Pay per impression. At EUR 3.50 CPM, 50,000 impressions cost EUR 175 in rights fees. +- **Flat monthly rate** — A fixed fee per month with an impression cap. At EUR 350/month with a 100,000 impression cap, a 3-month local campaign costs EUR 1,050 in rights fees, plus your creative production and media spend. + +Exclusivity is available as a premium option. If you want to be the only restaurant using Daan Janssen's likeness in the Netherlands, the agency can grant that — and the protocol automatically rejects competing requests for the duration of your contract. + +## What you control + +Every license is scoped along four dimensions, and you know the boundaries before you sign: + +- **Geographic scope.** A license for the Netherlands does not cover Germany. If you expand your campaign, you renegotiate. +- **Format scope.** A license for video does not grant audio rights. Each use type (likeness, voice, name, endorsement) is independently licensed. +- **Time scope.** Licenses have hard expiration dates. When the contract ends, generation credentials stop working. No manual cleanup required. +- **Content restrictions.** The talent's agency defines what is acceptable — categories, contexts, modification limits. These restrictions are part of the license terms, visible to you upfront. + +## How to get started + +1. **If you work with an agency**, ask if they use an AdCP-compatible buying platform. Many media agencies already have access to rights discovery and licensing through their existing tools. + +2. **If you buy direct**, visit the [AgenticAdvertising.org member directory](https://agenticadvertising.org/members) to find a platform partner. Member organizations build the tools that connect buyers to talent rights. + +3. **The platform handles search, negotiation, and credential management.** You approve the creative, set your budget, and define your campaign parameters. The platform translates that into protocol calls, manages the rights acquisition, and delivers generation credentials to your creative tools. + +## Next steps + + + + Search for licensable talent with pricing and availability. + + + Submit a binding request and receive generation credentials. + + + How brand.json and rights discovery fit together. + + diff --git a/dist/docs/3.0.13/brand-protocol/for-rights-holders.mdx b/dist/docs/3.0.13/brand-protocol/for-rights-holders.mdx new file mode 100644 index 0000000000..b6b9ebe508 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/for-rights-holders.mdx @@ -0,0 +1,123 @@ +--- +title: For talent and rights holders +description: "How talent and rights holders control AI-generated likeness, voice, and endorsement use through AdCP. Scoped credentials, geographic limits, approval workflows, impression tracking, and long-tail licensing revenue." +"og:title": "AdCP — For talent and rights holders" +--- + +AI can generate your face, your voice, and your endorsement — without asking. Generative tools are already creating synthetic celebrity content for advertising, and the legal frameworks have not caught up. If you are a public figure with licensable rights, the question is not whether AI will use your likeness. It is whether you will have any control over how. + +The Ad Context Protocol (AdCP) gives you that control. It is an open standard that lets your agency or management team set the rules for how AI systems use your identity in advertising — and enforce those rules at the point of generation. + +## How it works, in plain terms + +Imagine you are Daan Janssen, a Dutch Olympic speed skater. Your management agency, Loti Entertainment, represents your commercial rights. Here is what happens with AdCP in place: + +1. **Your agency publishes your availability.** Loti registers your rights in the protocol: what is available (likeness, voice, endorsement), where (Netherlands, Belgium, Germany), and at what price. They also list what is off-limits — product categories, competitors, content types. + +2. **A brand searches for you.** A restaurant chain in Amsterdam asks their AI media buyer to find a Dutch athlete for a campaign. The agent discovers your profile through AdCP, sees that you are available for food brands in the Netherlands, and that you fit their budget. + +3. **The brand requests your rights.** The agent submits a formal request: what they want to create, which formats, what countries, how many impressions, and for how long. This is a binding contractual request, not a casual inquiry. + +4. **Your agency reviews and approves.** Loti reviews the request against your preferences and existing contracts. If another food brand already has exclusivity in the Netherlands, the request is rejected automatically. If it passes, Loti sets the terms. + +5. **Time-limited credentials are issued.** The brand receives generation credentials — keys that allow specific AI providers (image generators, voice synthesis tools) to produce content using your likeness. These credentials expire when the contract ends. After that date, no provider will generate your likeness for that buyer. + +6. **Every use is tracked and reported.** Impressions are reported back to your agency for billing and cap enforcement. If the contract allows 100,000 impressions and the brand hits that limit, generation stops until they renegotiate. + +## What you control + +AdCP puts several mechanisms in your agency's hands: + +**Approval requirements.** Every creative can require your sign-off before it runs. The protocol supports a `pending_approval` status — nothing goes live until you or your representative says yes. + +**Content restrictions.** Your agency defines what is and is not acceptable. Categories, contexts, adjacent content, modification limits. These restrictions travel with the license. + +**Geographic limits.** Rights can be scoped to specific countries. A license for the Netherlands does not grant rights in the United States. + +**Time-bound credentials.** Generation credentials have hard expiration dates. When the license period ends, AI providers stop generating. This is not a policy — it is a technical constraint enforced by the provider. + +**Pricing transparency.** Your agency sets pricing: per-impression royalties, flat monthly rates, or both. Buyers see the price before they commit. No back-channel negotiations or opaque rate cards. + +**Disclosure requirements.** Every license can require that the brand disclose the use of AI-generated content featuring you. The disclosure text is part of the contract terms. + +**Exclusivity enforcement.** If a brand has exclusive rights to your likeness for restaurant advertising in the Netherlands, the protocol automatically rejects competing requests in that category and geography. + +## What your agency does + +Your agency or management company operates as your **rights agent** in the protocol. They do not need to build technology. They work with a platform that implements AdCP and configure your preferences: + +- Which rights are available (likeness, voice, name, endorsement) +- Geographic availability +- Pricing tiers and models +- Category exclusions (products or industries you will not endorse) +- Approval workflows (automatic for some categories, manual review for others) +- Exclusivity terms + +The rights agent handles discovery, negotiation, credential issuance, and usage tracking. Your involvement is limited to setting preferences and reviewing creative concepts that require approval. + +## The long-tail opportunity + +Traditional endorsement deals require photo shoots, contract negotiations, and weeks of back-and-forth. The transaction costs mean only big brands can afford to work with you. A local restaurant, a regional gym chain, a neighborhood car dealership — they would love to feature you, but the deal is too small to justify the process. + +AdCP changes the math. Because discovery, negotiation, and credential issuance are automated, small deals become viable. A steakhouse in Amsterdam can license your likeness for EUR 350 per month. A fitness studio in Rotterdam can license your voice for EUR 200 per month. Individually, these are small. Collectively, they add up. + +Twenty local businesses at EUR 350 per month is EUR 84,000 per year — revenue from deals that would never have happened through traditional channels. Your agency sets the price, the protocol handles the rest, and you approve the creatives that need your sign-off. + +## What the approval experience looks like + +When a brand wants to use your likeness in a campaign, you or your representative see a notification. The delivery method depends on the platform your agency uses — it could be an email, a dashboard alert, or a push notification in an app. + +The notification includes the key details: + +- Which brand is making the request +- What they want to create (a video ad, a display banner, a voice spot) +- Which formats and dimensions +- Where it will run (countries, channels) +- How long the license lasts + +From there, you or your representative have three options: approve the request, request changes, or reject it. There is no ambiguity and no pressure to respond immediately — the creative cannot be generated until a decision is recorded. + +If you approve, the brand receives generation credentials scoped to exactly what was agreed. If you reject, no credentials are issued and the brand cannot generate content using your likeness for that campaign. If you request changes, the brand can revise and resubmit. + +In protocol terms, "request changes" is a rejection with suggestions. Your agent rejects the request but includes actionable alternatives — "available in a different market" or "try a shorter license period." The buyer's agent sees the suggestions and can adjust automatically. A rejection without suggestions signals "no, full stop" — the buyer moves on without knowing your internal reasons. + +Every decision is logged. Your agency has a complete record of what was requested, what was approved, and what was denied. + +## Addressing common concerns + +**"Someone will generate my likeness without permission."** +AdCP-compliant providers check for rights credentials before generating content using a known identity. Without a valid credential, generation is blocked. The protocol does not prevent all unauthorized use — bad actors can still misuse open-source models — but it creates a clear, enforceable standard for the legitimate advertising ecosystem. + +**"What happens if someone uses my likeness without going through AdCP?"** +Your agency can use the audit trail from legitimate AdCP usage to establish what authorized use looks like. If unauthorized use appears in the market, that trail becomes evidence. AdCP does not police the internet — but it creates the paper trail that your legal team needs. + +**"I will lose control once I license my rights."** +Licenses are scoped by use, geography, time period, and content type. A license to use your likeness in video ads for a restaurant in the Netherlands does not grant rights to use your voice in audio ads for a car brand in Japan. Each dimension is independently controlled. + +**"I won't know how my likeness is being used."** +Usage reporting is built into the protocol. Every impression against your rights is tracked and reported back to your agency. This creates an audit trail for billing, compliance, and contract enforcement. + +**"Pricing will be a race to the bottom."** +You set the price. The protocol supports multiple pricing models — CPM-based royalties, flat rates, impression caps with overage charges. Buyers see transparent pricing and either accept it or move on. There is no auction or price compression mechanism. + +## What AdCP does not solve yet + +The protocol is honest about its current limitations: + +- **Mid-contract revocation** is supported through a revocation webhook. If something goes wrong — a talent controversy, a contract violation, a brand conflict — your agency can revoke rights immediately. The buyer provides a revocation webhook when they acquire rights, and your agency sends a notification with a reason and effective date. The buyer is responsible for stopping creative delivery. However, the protocol does not yet enforce revocation at the provider level — credential invalidation depends on provider cooperation. +- **Open-source model enforcement** is outside the protocol's scope. AdCP works with providers who participate in the credential system. It cannot prevent someone from using an uncontrolled model to generate your likeness. +- **Deepfake detection** is a separate problem. AdCP handles authorized use. Detecting and responding to unauthorized synthetic content requires different tools. + +## Next steps + +If you are a rights holder or represent one: + +1. **Ask your agency if they work with an AdCP-compatible rights management platform.** Many talent agencies and management companies already use platforms that support the protocol. If yours does, the setup is straightforward — your agency configures your preferences and approval rules on the platform they already use. + +2. **If they do not, have them visit the [AgenticAdvertising.org member directory](https://agenticadvertising.org/members) to find a platform partner.** Member organizations build the tools that connect talent rights to the advertising ecosystem. Your agency picks a platform, and that platform handles the technical integration. + +3. **The platform partner handles the technical setup — you configure your preferences and approval rules.** You decide what is available, where, at what price, and what requires your personal sign-off. The platform translates those preferences into protocol-compliant rights listings that AI media buyers can discover and negotiate against. + +For a deeper look at how the protocol operates, read the [rights discovery](/dist/docs/3.0.13/brand-protocol/tasks/get_rights) and [rights acquisition](/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights) documentation. + +The advertising industry is adopting AI-generated content. The question for rights holders is whether that adoption happens with your participation and compensation, or without it. AdCP is designed to make sure you have a seat at the table. diff --git a/dist/docs/3.0.13/brand-protocol/index.mdx b/dist/docs/3.0.13/brand-protocol/index.mdx new file mode 100644 index 0000000000..fdbe48d0f9 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/index.mdx @@ -0,0 +1,107 @@ +--- +title: Brand protocol +sidebarTitle: Overview +"og:image": /images/walkthrough/brand-before-after.png +description: "brand.json is the open standard that tells AI agents how to use your brand correctly. Publish logos, colors, tone, and restrictions in one machine-readable file that every agent in the AdCP ecosystem obeys." +"og:title": "AdCP — Brand protocol" +--- + +AI agents are generating content for your brand right now. Creative tools are picking logo variants, choosing color palettes, and writing copy in what they think is your voice. They pull this from wherever they can find it — cached web pages, scraped style guides, outdated press kits. + +You have no control over what they find. You have no way to tell them what not to do. + +## How it works + +You publish a single set of brand rules that every AI agent in the advertising ecosystem can read — your logos, colors, tone of voice, and what they must never do. The format is called `brand.json`, and it lives on your domain. + +A creative agent gets a brief: "Lunch promotion for Bistro Oranje, two-course menu, EUR 18.50." + +Split panel: left side shows an AI agent frantically scraping mismatched brand elements, producing an off-brand ad with wrong colors and text over food imagery; right side shows the same agent calmly reading brand.json and producing a clean, on-brand ad with correct logo, palette, and layout + +**Without brand.json**, the agent scrapes the website, finds an old logo, guesses at colors from the website, and puts a headline over the hero food shot. Close enough — until the brand team sees it. + +**With brand.json**, the agent fetches the file, pulls the correct wordmark, applies the exact palette, reads the warm tone, and sees the restriction against text over food imagery. It generates a food-forward composition with the headline below the image. No guessing. No corrections. + +The brand team did not brief the creative agent on any of this. The file did it for them. + +Every brand system in the world can serve a logo. Almost none of them can say "never place text over food imagery" in a way that an AI agent actually obeys. `brand.json` can — because restrictions are machine-readable rules, not guidelines buried in a PDF. + +## See what agents know about your brand + +Enter any domain on the [AgenticAdvertising.org brand registry](https://agenticadvertising.org/brands) to see what AI agents find for that brand today. Most brands return nothing — which means agents are guessing. Brands with `brand.json` show exactly what agents see. + +## Two tiers: public and authorized + +Not everything belongs in a public file. `brand.json` handles this with two access levels: + +| Level | What it includes | Who sees it | +|-------|-----------------|-------------| +| **Public** | Name, logo, colors, tagline, basic tone | Any AI agent | +| **Authorized** | High-res assets, voice synthesis, detailed guidelines | Partners you approve | + +The public tier contains what is already on your website. The authorized tier is for the assets and guidelines you share only with agencies and partners. You decide who gets access by linking accounts — no data leaks to agents you have not approved. + +## One file, many uses + +Brands exist in hierarchies — a holding company, its brands, their sub-brands and franchise locations. `brand.json` handles this: start at any domain and the protocol finds the canonical brand identity. The buy side gets the same structured identity that publishers already have on the sell side. + +- **License real talent through the same file.** Your `brand.json` can declare which celebrity likenesses and voices are available, and on what terms. A buyer agent finds talent, negotiates pricing, and gets authorization for AI creative tools — without a single email. [Follow the full rights licensing story →](/dist/docs/3.0.13/brand-protocol/walkthrough-rights-licensing) +- **Keep every sub-brand consistent.** A franchise domain points to the parent brand in two lines. Every location inherits the full identity — logos, colors, tone, restrictions — with no separate configuration. +- **Let the supply chain verify in real time.** Before serving an ad, any participant can confirm the talent license is still active and covers the right geography. + +## Start simple + +The smallest useful `brand.json` is just a name and a logo: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { "domain": "novabrands.com", "name": "Nova Brands" }, + "brands": [{ + "id": "nova", + "names": [{"en": "Nova Brands"}], + "logos": [{"url": "https://novabrands.com/logo.svg"}] + }] +} +``` + +Add colors when you are ready. Add tone guidelines later. Add visual restrictions when you see AI getting it wrong. The file grows with your needs. + +## Ecosystem support + +`brand.json` feeds into a growing ecosystem of agentic tools that read and act on your brand data: + +- **Buyer agents** that comply with AdCP reference your `brand.json` during campaign planning to ensure media buys align with your brand guidelines +- **Creative agents** pull your colors, logos, restrictions, and tone of voice when generating on-brand assets +- **The AgenticAdvertising.org registry** indexes published `brand.json` files so buyer and seller agents can discover your brand programmatically +- **Any MCP-compatible AI tool** that implements the brand protocol can read your `brand.json` — the format is open and not locked to a single platform + +The more tools that adopt the protocol, the more value your `brand.json` delivers without any additional work on your part. + +## Go deeper + +**For brand teams:** + + + + How to license real talent for AI-generated campaigns. + + + How AdCP protects and monetizes talent rights. + + + Follow a buyer from talent discovery through acquisition and revocation. + + + +**For developers:** + + + + Full technical specification for the file format. + + + Implement a brand agent that serves identity and rights. + + diff --git a/dist/docs/3.0.13/brand-protocol/key-concepts.mdx b/dist/docs/3.0.13/brand-protocol/key-concepts.mdx new file mode 100644 index 0000000000..0cec2e9916 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/key-concepts.mdx @@ -0,0 +1,298 @@ +--- +title: Brand protocol +description: "AdCP brand protocol concepts: houses, brands, brand agents, Keller architecture types, brand.json discovery, resolution flow, and how brand identity feeds into creative generation and media buying." +"og:title": "AdCP — Brand protocol key concepts" +sidebarTitle: Key concepts +--- + +The Brand Protocol enables brands to claim their identity and establish a verifiable source of truth through a standardized discovery mechanism. By hosting a `brand.json` file at a well-known location, brands can declare their identity, brand hierarchy, and optionally designate official brand agents. + +## Purpose + +The Brand Protocol addresses buy-side identity in advertising, providing the same clarity that the Property Protocol provides for the sell-side: + +| Sell Side | Buy Side | Description | +|-----------|----------|-------------| +| Publisher | **House** | Corporate entity (Nike, Inc., P&G) | +| Property | **Brand** | Advertising identity (Nike, Air Jordan) | +| Inventory | **Destination** | Landing pages, apps | + +This parallel structure makes brands first-class citizens in AdCP. + +## How it works + +Brands host a `brand.json` file at `/.well-known/brand.json` on their domain. The file can take one of four forms: + +1. **Brand Agent**: Points to an MCP agent that provides brand information +2. **House Portfolio**: Contains full brand hierarchy with all brands and properties +3. **House Redirect**: Points to a house domain that contains the portfolio +4. **Authoritative Location**: Points to a hosted brand.json URL + +```mermaid +sequenceDiagram + participant Agent as Buyer Agent + participant Domain as Brand Domain + participant House as House Domain + participant BrandAgent as Brand Agent (MCP) + + Agent->>Domain: GET /.well-known/brand.json + alt House Redirect + Domain-->>Agent: { "house": "nikeinc.com" } + Agent->>House: GET /.well-known/brand.json + House-->>Agent: Full portfolio (house + brands) + else Brand Agent + Domain-->>Agent: { "brand_agent": { "url": "..." } } + Agent->>BrandAgent: MCP: get brand identity + BrandAgent-->>Agent: Brand identity data + else House Portfolio + Domain-->>Agent: Full portfolio (house + brands) + end +``` + +## Brand architecture + +The protocol supports Keller's brand architecture models: + +| Type | Description | Example | +|------|-------------|---------| +| `master` | Primary brand of house | Nike for Nike, Inc. | +| `sub_brand` | Carries parent name | Nike SB | +| `endorsed` | Independent identity, backed by parent | Air Jordan "by Nike" | +| `independent` | Operates separately | Converse under Nike, Inc. | + +## Example: house portfolio + +A house domain with multiple brands: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "nikeinc.com", + "name": "Nike, Inc.", + "architecture": "hybrid" + }, + "brands": [ + { + "id": "nike", + "names": [{"en": "Nike"}, {"zh": "耐克"}], + "keller_type": "master", + "properties": [ + {"type": "website", "identifier": "nike.com", "primary": true}, + {"type": "mobile_app", "store": "apple", "identifier": "com.nike.omega"} + ] + }, + { + "id": "air_jordan", + "names": [{"en": "Air Jordan"}, {"en": "Jordan"}], + "keller_type": "endorsed", + "parent_brand": "nike", + "properties": [ + {"type": "website", "identifier": "jordan.com"}, + {"type": "website", "identifier": "jumpman23.com"} + ] + } + ] +} +``` + + +Properties can also represent inventory you sell but don't own. Use `relationship: "delegated"` for properties you manage (like a publisher network) or `relationship: "ad_network"` for properties you sell as an exchange/SSP. See [property relationships](/dist/docs/3.0.13/brand-protocol/brand-json#property-relationships). + + +## Example: brand agent + +A brand with an MCP agent that provides brand information: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "brand_agent": { + "url": "https://agent.acme.com/mcp", + "id": "acme_brand_agent" + } +} +``` + +The agent provides brand identity data (logos, colors, tone) on behalf of the brand. + +## Example: house redirect + +A brand domain pointing to its house: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "house": "nikeinc.com" +} +``` + +## Resolution flow + +Given any domain, the protocol resolves to a canonical brand: + +``` +jumpman23.com + -> fetch /.well-known/brand.json + -> { "house": "nikeinc.com" } + -> fetch nikeinc.com/.well-known/brand.json + -> search brands[] for property matching "jumpman23.com" + -> found in Air Jordan brand's properties + -> Result: { house: "nikeinc.com", brand_id: "air_jordan" } +``` + +## Brand resolution sources + +There are three ways to resolve brand identity, each returning the same data structure: + +| Source | How it works | When to use | +|--------|-------------|-------------| +| [`resolve_brand`](#mcp-tools) | Fetches `/.well-known/brand.json`, extracts brand identity | Brand publishes a `brand.json` file | +| [Brand enrichment](/dist/docs/3.0.13/registry/index#brand-resolution) | Fetches from Brandfetch, saves identity to registry | No `brand.json` available, need enrichment | +| [Registry lookup](/dist/docs/3.0.13/registry/index#brand-resolution) | Returns community or enriched identity from the registry | Brand already registered | + +Regardless of source, the result is a brand identity that can be referenced by any AdCP task via a brand reference (`{ "domain": "...", "brand_id": "..." }`). + +## Use cases + +### Creative generation + +When a creative agent needs brand assets: + +1. Resolve domain to canonical brand via brand.json +2. Get brand identity data (from brand.json, agent, or registry) +3. Generate on-brand creatives + +### Brand verification + +When verifying brand claims: + +1. Fetch brand.json from claimed domain +2. Follow redirects to house if needed +3. Verify brand exists in portfolio + +### Reporting roll-up + +When aggregating brand performance: + +1. Resolve all brand domains to canonical IDs +2. Group by house for corporate-level reporting +3. Optionally include/exclude sub-brands + +## Brand context in requests + +AdCP tasks accept a `brand` reference that identifies the brand by domain and optional brand_id. The system resolves this reference to the full brand identity at execution time. + +```json +{ + "brand": { + "domain": "acmecorp.com", + "brand_id": "tide" + } +} +``` + +For single-brand domains, `brand_id` is optional: + +```json +{ + "brand": { + "domain": "acmecorp.com" + } +} +``` + +Brand identity data is resolved from `brand.json` or the registry — never passed inline. + +## Caching + +Brand information changes infrequently (logo updates, guideline refreshes). Recommended caching: + +- **HTTP headers**: Use standard `ETag`, `Last-Modified`, and `Cache-Control` headers +- **Default TTL**: 24 hours for validated brand.json files +- **Failed lookups**: Cache for 1 hour before retrying +- **last_updated field**: Informational timestamp in brand.json for staleness checks + +Agents should respect HTTP caching headers when fetching brand.json files. + +## Brand protocol tasks + +Agents that implement the brand protocol declare `supported_protocols: ["brand"]` in `get_adcp_capabilities`. The specific tasks they implement define their role: + +| Agent capability | Tasks | Example | +|-----------------|-------|---------| +| DAM | `get_brand_identity` | Enterprise brand portal, asset management | +| Rights management | `get_rights` + `acquire_rights` | Talent licensing, music sync, stock media | +| Both | All brand tasks | Talent agency managing identity and rights | + +### get_brand_identity + +Returns brand identity data that's richer, more dynamic, or more access-controlled than static brand.json. Core identity (house, names, description, logos) is always public. Linked accounts (via `sync_accounts`) get deeper data: high-res assets, voice synthesis configs, tone guidelines, and rights availability. + +### Rights discovery via brand.json + +Brands with licensable rights declare a `rights_agent` in their brand.json. This makes rights crawlable and indexable without MCP calls: + +```json +{ + "id": "daan_janssen", + "names": [{"en": "Daan Janssen"}], + "description": "Dutch Olympic speed skater, 2x gold medalist", + "rights_agent": { + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment", + "available_uses": ["likeness", "voice", "endorsement"], + "right_types": ["talent"], + "countries": ["NL", "BE", "DE"] + } +} +``` + +The `brand_agent` provides identity data (logos, tone, assets). The `rights_agent` provides licensing (discovery, pricing, acquisition). They can be the same agent or different ones. + +### get_rights + +Search for licensable rights across a brand agent's roster. Returns matches with pricing. Discovery is natural-language-first — no taxonomy, LLMs interpret intent from the query. + +### acquire_rights + +Binding contractual request to clear rights. The buyer selects a `pricing_option_id` from `get_rights` and provides campaign details. Returns terms, generation credentials for LLM providers, and disclosure requirements. + +### Generation credentials + +Rights management agents coordinate with LLM providers (Midjourney, ElevenLabs, etc.) to issue scoped credentials. The rights agent sets up the permission; the provider enforces at generation time. Any creative agent can use the credentials. + +### Creative lifecycle + +Creative manifests carry an optional `rights` array — each entry is a rights constraint from a different rights holder. A single creative may combine talent likeness + music license, each with different validity periods and country restrictions. For v1, rights constraints are informational metadata. + +Usage is reported back to the rights agent via `report_usage` with the `rights_id` field for cap tracking and billing. + +## MCP tools + +The Brand Protocol provides MCP tools for programmatic access: + +| Tool | Description | +|------|-------------| +| `resolve_brand` | Resolve domain to canonical brand identity | +| `validate_brand_json` | Validate a domain's brand.json | +| `validate_brand_agent` | Test brand agent reachability | + +## Learn more + + + + Complete technical specification for the brand.json file format. + + + Retrieve brand identity data from a brand agent. + + + Search for licensable rights with pricing. + + + Acquire rights with contractual clearance. + + diff --git a/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights.mdx b/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights.mdx new file mode 100644 index 0000000000..a948bd4690 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights.mdx @@ -0,0 +1,309 @@ +--- +title: acquire_rights +description: "acquire_rights is the AdCP task for binding rights acquisition. Submit a pricing option and campaign details to receive generation credentials, rights constraints, and disclosure requirements from a brand agent." +"og:title": "AdCP — acquire_rights" +testable: true +--- + + +**Experimental.** Brand rights lifecycle (`get_rights`, `acquire_rights`, `update_rights`) 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 any of these tasks MUST declare `brand.rights_lifecycle` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Binding contractual request to acquire rights from a brand agent. Parallels `create_media_buy` — select a `pricing_option_id` from `get_rights` and provide campaign details. The agent clears against existing contracts and returns terms, generation credentials, and disclosure requirements. + +## Schema + +- **Request**: [`acquire-rights-request.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/acquire-rights-request.json) +- **Response**: [`acquire-rights-response.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/acquire-rights-response.json) + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} + +## Response time + +Seconds to minutes for `acquired` or `rejected`. The `pending_approval` status means the rights holder needs to review — resolution may take hours to days. + +## Quick start + + +```json Request +{ + "rights_id": "janssen_likeness_voice", + "pricing_option_id": "monthly_exclusive", + "buyer": { + "domain": "bistro-oranje.nl", + "brand_id": "bistro_oranje" + }, + "campaign": { + "description": "AI-generated video ads for Bistro Oranje steakhouse featuring Daan Janssen", + "uses": ["likeness", "voice"], + "countries": ["NL"], + "format_ids": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_social_1080x1920" } + ], + "estimated_impressions": 50000, + "start_date": "2026-04-01", + "end_date": "2026-06-30" + }, + "revocation_webhook": { + "url": "https://buyer.bistro-oranje.nl/webhooks/revocation", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "whsk_bo_abc123...shared_secret_min_32_chars" + } + }, + "idempotency_key": "acq_bo_janssen_2026q2_001", + "push_notification_config": { + "url": "https://buyer.bistro-oranje.nl/webhooks/adcp/acquire_rights/op_abc123", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "whsk_bo_xyz789...shared_secret_min_32_chars" + } + } +} +``` + +```json Response (acquired) +{ + "rights_id": "janssen_likeness_voice", + "status": "acquired", + "brand_id": "daan_janssen", + "terms": { + "pricing_option_id": "monthly_exclusive", + "amount": 350, + "currency": "EUR", + "period": "monthly", + "uses": ["likeness", "voice"], + "impression_cap": 100000, + "overage_cpm": 4.00, + "start_date": "2026-04-01", + "end_date": "2026-06-30", + "exclusivity": { + "scope": "Exclusive licensee for Daan Janssen in NL for food/restaurant brands", + "countries": ["NL"] + } + }, + "generation_credentials": [ + { + "provider": "midjourney", + "rights_key": "rk_mj_abc123...", + "uses": ["likeness"], + "expires_at": "2026-06-30T23:59:59Z" + }, + { + "provider": "elevenlabs", + "rights_key": "rk_el_def456...", + "uses": ["voice"], + "expires_at": "2026-06-30T23:59:59Z" + } + ], + "rights_constraint": { + "rights_id": "janssen_likeness_voice", + "rights_agent": { "url": "https://agent.lotientertainment.com/mcp", "id": "loti_entertainment" }, + "valid_from": "2026-04-01T00:00:00Z", + "valid_until": "2026-06-30T23:59:59Z", + "uses": ["likeness", "voice"], + "countries": ["NL"], + "impression_cap": 100000, + "approval_status": "approved", + "verification_url": "https://agent.lotientertainment.com/rights/rts_abc123/verify" + }, + "restrictions": [ + "All generated creatives must be submitted for approval before distribution", + "No modification of talent likeness beyond approved AI generation parameters" + ], + "disclosure": { + "required": true, + "text": "Features AI-generated likeness of Daan Janssen, used under license from Loti Entertainment" + }, + "approval_webhook": { + "url": "https://agent.lotientertainment.com/rights/rts_abc123/approve", + "authentication": { + "schemes": ["Bearer"], + "credentials": "rk_approve_abc123...token_min_32_chars" + } + }, + "usage_reporting_url": "https://agent.lotientertainment.com/rights/rts_abc123/usage" +} +``` + +```json Response (pending approval) +{ + "rights_id": "janssen_likeness_voice", + "status": "pending_approval", + "brand_id": "daan_janssen", + "detail": "Creative concept requires talent approval per contract terms", + "estimated_response_time": "48h" +} +``` + +```json Response (rejected — actionable) +{ + "rights_id": "janssen_likeness_voice", + "status": "rejected", + "brand_id": "daan_janssen", + "reason": "Active exclusivity with another brand for food/restaurant in NL through 2026-09-30", + "suggestions": [ + "Available in BE and DE markets", + "Available in NL after 2026-10-01" + ] +} +``` + +```json Response (rejected — final) +{ + "rights_id": "janssen_likeness_voice", + "status": "rejected", + "brand_id": "daan_janssen", + "reason": "This violates our public figures brand guidelines" +} +``` + +```json Response (error) +{ + "errors": [ + { + "code": "pricing_option_unavailable", + "message": "Pricing option 'monthly_exclusive' is no longer available for this rights offering" + } + ] +} +``` + + +## Parameters + +### Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `rights_id` | string | Yes | Rights offering identifier from `get_rights` | +| `pricing_option_id` | string | Yes | Selected pricing option | +| `buyer` | brand-ref | Yes | Buyer's brand identity | +| `campaign.description` | string | Yes | How the rights will be used | +| `campaign.uses` | string[] | Yes | Specific rights uses for this campaign | +| `campaign.countries` | string[] | No | Countries where the campaign will run | +| `campaign.format_ids` | format-id[] | No | Creative formats that will be produced | +| `campaign.estimated_impressions` | integer | No | Estimated total impressions | +| `campaign.start_date` | date | No | Campaign start date | +| `campaign.end_date` | date | No | Campaign end date | +| `revocation_webhook` | push-notification-config | Yes | Webhook for revocation notifications. If the rights holder needs to revoke rights, they POST a [revocation-notification](https://adcontextprotocol.org/schemas/3.0.13/brand/revocation-notification.json) to this URL. | +| `idempotency_key` | string | No | Client-generated key for safe retries. Resubmitting with the same key returns the original response. | +| `push_notification_config` | push-notification-config | No | Webhook for async status updates if the acquisition requires approval. See [push notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks). | + +### Response statuses + +The response uses a discriminated union on `status`: + +| Status | Description | Key fields | +|--------|-------------|------------| +| `acquired` | Rights cleared, credentials issued | `terms`, `generation_credentials`, `rights_constraint`, `disclosure` | +| `pending_approval` | Requires rights holder review | `detail`, `estimated_response_time` | +| `rejected` | Request denied | `reason`, `suggestions` (optional) | + +When `suggestions` is present on a rejected response, the rejection is actionable — the buyer can adjust their request and retry. When `suggestions` is absent, the rejection is final and the buyer should not retry for this rights/talent combination. This convention applies consistently across `acquire_rights` rejections, `get_rights` excluded results, and creative approval rejections. + +## Request validation + +Two campaign-field validations are normative for `acquire_rights`. Both produce `INVALID_REQUEST` with the offending `field` populated and `recovery: "correctable"` (the buyer can fix the request and retry). + +### Expired campaign window + +Brand agents MUST reject with `INVALID_REQUEST` and `field: "campaign.end_date"` when `campaign.end_date` is in the past at the time of the request. Acquiring rights for a window that has already elapsed produces a zero-duration grant and is almost always a buyer-side bug — surfacing it deterministically is more useful than silently issuing credentials that immediately expire. + +Unlike [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy), which supports an `any_of` past-start auto-adjust pattern (a flight can be time-shifted forward without re-licensing), rights grants are not time-shiftable: the contract attaches to the requested period, so reject-only is the correct contract for `acquire_rights`. + +Brand agents MAY also reject when `campaign.start_date` is more than the rights agent's configured grace window in the past (typically the rights agent rejects start dates earlier than `now - 24h` since rights cannot be retroactively granted); that decision is contract-specific and SHOULD use `field: "campaign.start_date"`. The `end_date < now` check is the normative floor. + +### CPM-priced rights under a governance plan + +When the buyer's request carries an intent-phase `governance_context` token on the [protocol envelope](/dist/docs/3.0.13/building/by-layer/L1/security) (the buyer's plan is governed — see [Buyer-side governance invocation](/dist/docs/3.0.13/governance/campaign/specification#buyer-side-governance-invocation)) and the selected pricing option has `model: "cpm"`, `campaign.estimated_impressions` is the input the brand agent uses to project commitment against remaining plan budget. To make that projection deterministic across implementations: + +- Brand agents MUST reject with `INVALID_REQUEST` and `field: "campaign.estimated_impressions"` when a `governance_context` is present, the selected `pricing_option.model` is `"cpm"`, and `campaign.estimated_impressions` is either omitted or `0`. Implementer-chosen defaults (e.g., assuming 1M impressions) are non-conformant — they hide a policy decision inside each implementation and produce different governance outcomes for identical requests. +- When `estimated_impressions` is provided and non-zero, the projected commitment is `(pricing_option.price / 1000) × campaign.estimated_impressions`, evaluated in `pricing_option.currency`. If `pricing_option.currency` differs from the governance plan's budget currency (as carried on the plan), the brand agent MUST reject with `INVALID_REQUEST` and `field: "pricing_option_id"` — currency conversion is not specified for governance projection, so currency-mismatched offers cannot be cleared against a governed plan. +- If the projected commitment exceeds the buyer's remaining plan budget, the agent MUST reject with `INVALID_REQUEST` and `field: "campaign.estimated_impressions"`, populating `reason` with the projected commitment and remaining budget so the buyer can adjust. +- Non-CPM pricing options (`model: "flat_rate"`, etc.) commit the flat amount regardless of impression volume; brand agents MUST NOT require `estimated_impressions` for governance projection on those options. Buyers MAY still provide `estimated_impressions` for cap-tracking purposes. + +Requests without a `governance_context` token are unaffected by this validation — `estimated_impressions` remains optional in that case (sellers MAY refuse to transact ungoverned plans as a matter of commercial policy, per [Buyer-side governance invocation](/dist/docs/3.0.13/governance/campaign/specification#buyer-side-governance-invocation), but that refusal is independent of this projection rule). + +## Generation credentials + +When rights are acquired, the agent coordinates with LLM providers to issue scoped credentials: + +1. Agent clears the rights against existing contracts +2. Agent tells the provider (e.g., Midjourney): "Issue a rights key for this talent, licensed to this buyer" +3. Agent returns the credentials to the buyer + +**Any creative agent** can use these credentials. The LLM provider enforces usage constraints at generation time — the rights agent sets up the permission, the provider is the gatekeeper. + +| Field | Type | Description | +|-------|------|-------------| +| `provider` | string | LLM/generation service (e.g., "midjourney", "elevenlabs") | +| `rights_key` | string | Scoped API key for generating rights-cleared content | +| `uses` | string[] | Rights uses this credential covers | +| `expires_at` | datetime | When the credential expires (provider-determined) | +| `endpoint` | uri | Provider endpoint for rights-scoped generation (optional, uses provider default if omitted) | + +## Rights constraint + +When `status` is `acquired`, the response includes a `rights_constraint` object: + +| Field | Type | Description | +|-------|------|-------------| +| `rights_constraint` | object | Pre-built constraint for creative manifests. Contains validity period, country restrictions, and impression cap from agreed terms. Embed directly in the manifest's `rights` array. | + +## Creative lifecycle + +After acquiring rights: + +1. **Generate**: Creative agent uses `generation_credentials` to produce content +2. **Manifest**: Embed the `rights_constraint` from the `acquire_rights` response directly into the creative manifest's `rights` array. The rights agent pre-builds this constraint with the correct validity period, country restrictions, and impression cap from the agreed terms. +3. **Approve**: POST a [`creative-approval-request`](https://adcontextprotocol.org/schemas/3.0.13/brand/creative-approval-request.json) to the `approval_webhook` URL, authenticating with the provided credentials. The response is a [`creative-approval-response`](https://adcontextprotocol.org/schemas/3.0.13/brand/creative-approval-response.json) with status `approved`, `rejected`, or `pending_review`. If `pending_review`, poll the returned `status_url` for updates (suggested: every 5 minutes, back off to every 30 minutes after 1 hour). +4. **Serve**: Place approved creative via `create_media_buy`, respecting country and date restrictions +5. **Report**: Use [`report_usage`](/dist/docs/3.0.13/accounts/tasks/report_usage) with `rights_id` for cap tracking and billing + +If `acquire_rights` returns `pending_approval` and you provided `push_notification_config`, you'll receive a webhook notification when the status changes to `acquired` or `rejected`. Otherwise, re-call `acquire_rights` with the same `rights_id` and `idempotency_key` after the `estimated_response_time` interval. Set `approval_status: 'pending'` on any creative manifests built during this period. + +## Revocation + +If the rights holder needs to revoke rights (talent controversy, contract violation, etc.), they POST a [`revocation-notification`](https://adcontextprotocol.org/schemas/3.0.13/brand/revocation-notification.json) to the buyer's `revocation_webhook`, authenticating with the credentials provided at acquisition time. The notification contains an `idempotency_key` (required, used for deduplication across retries), `rights_id`, `brand_id`, `reason`, and `effective_at` timestamp. + +The buyer is responsible for: +- Deduplicating by `idempotency_key` — the same revocation may be delivered multiple times; see [Push Notifications — Reliability](/dist/docs/3.0.13/building/by-layer/L3/webhooks#reliability) for the canonical dedup contract +- Stopping creative delivery by `effective_at` +- Removing or replacing affected creatives from active campaigns +- Ceasing use of generation credentials (providers may also invalidate credentials independently) + +Partial revocation is supported — if `revoked_uses` is present, only those uses are revoked (e.g., voice revoked but likeness remains). + +### Acknowledging revocations + +Return HTTP `200` immediately upon receiving and validating a revocation notification. The rights holder retries on non-`2xx` responses using exponential backoff (1s, 5s, 30s, 5m, 30m). After 6 failed attempts, the rights holder may escalate through other channels. + +All webhook signing follows the AdCP [push notification signing profile](/dist/docs/3.0.13/building/by-layer/L3/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "webhook-signing"` key published at its brand.json `agents[]` entry), with the deprecated HMAC-SHA256 fallback available when the rights holder populates `authentication.credentials` on the webhook registration. + +## Impression caps and overage + +When `terms.impression_cap` is set, it is a **soft cap**. Delivery is not automatically halted at the cap — the buyer is responsible for monitoring usage via `report_usage` and managing delivery accordingly. Impressions beyond the cap are billed at `terms.overage_cpm`. If the rights holder wants a hard cap (no delivery beyond the limit), they specify this in `restrictions`. + +## Usage reporting + +The `usage_reporting_url` in the acquired response is a convenience endpoint provided by the rights agent for HTTP-based impression reporting. It accepts the same payload as the [`report_usage`](/dist/docs/3.0.13/accounts/tasks/report_usage) MCP task. Use whichever integration is simpler for your pipeline — the MCP tool for agent-to-agent workflows, or the URL for direct HTTP calls from ad servers. + +## Renewal and updates + +To extend a rights grant, adjust impression caps, change pricing, or pause/resume, use [`update_rights`](/dist/docs/3.0.13/brand-protocol/tasks/update_rights). Extended grants receive re-issued generation credentials and an updated `rights_constraint` for re-embedding in manifests. + +## Next steps + + + + Extend, adjust, or pause an existing rights grant. + + + Report impressions against rights grants for billing and cap tracking. + + + Rights constraints on creative manifests. + + diff --git a/dist/docs/3.0.13/brand-protocol/tasks/get_brand_identity.mdx b/dist/docs/3.0.13/brand-protocol/tasks/get_brand_identity.mdx new file mode 100644 index 0000000000..2ad4ef6994 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/tasks/get_brand_identity.mdx @@ -0,0 +1,215 @@ +--- +title: get_brand_identity +description: "get_brand_identity is the AdCP task for retrieving brand data from a brand agent. Returns logos, colors, fonts, visual guidelines, tone, and voice synthesis config with public and authorized access tiers." +"og:title": "AdCP — get_brand_identity" +testable: true +--- + +Retrieve brand identity data from a brand agent. Core identity (house, names, description, logos) is always public — any agent can discover who a brand is without authentication. Linked accounts get deeper data: high-res assets, voice synthesis configs, tone guidelines, and rights availability. + +## Schema + +- **Request**: [`get-brand-identity-request.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/get-brand-identity-request.json) +- **Response**: [`get-brand-identity-response.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/get-brand-identity-response.json) + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} + +## Response time + +Typically under 2 seconds for identity data. Authorized responses with large asset collections may take longer. + +## Public by default + +Brand identity is public data. Any agent can call `get_brand_identity` without a linked account and receive the brand's core identity: house, names, description, industries, and basic logos. This is the same data available in `brand.json` — `get_brand_identity` provides it via MCP for agents that prefer a structured call over fetching and parsing the file. + +The registry enforces this: every brand indexed from `brand.json` is publicly discoverable. You can always find out what house a brand belongs to, what it's called, and what it does. + +Authorized callers (linked via [`sync_accounts`](/dist/docs/3.0.13/accounts/overview)) get deeper data on top of the public baseline. + +Account linking is a one-time setup: a buyer agent calls [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts) on the brand agent, providing its brand reference. After that, the buyer's `get_brand_identity` requests are recognized as authorized. + +| Level | What you get | +|-------|-------------| +| **Public** (no linked account) | House, names, description, industries, keller_type, basic logos, tagline | +| **Authorized** (linked via `sync_accounts`) | Everything above, plus: high-res assets, voice synthesis, tone guidelines, content restrictions, rights availability | + +If a request includes `fields` that require authorization the caller does not have, those fields are silently omitted. The response includes `available_fields` listing what sections exist but were not returned, so the caller knows what they would gain by linking their account. + +## Quick start + + +```json Request (public) +{ + "brand_id": "daan_janssen" +} +``` + +```json Response (public) +{ + "brand_id": "daan_janssen", + "house": { "domain": "lotientertainment.com", "name": "Loti Entertainment" }, + "names": [{"en": "Daan Janssen"}], + "description": "Dutch Olympic speed skater, 2x gold medalist", + "industries": ["sports_fitness"], + "keller_type": "independent", + "logos": [ + { "url": "https://cdn.lotientertainment.com/janssen/headshot.jpg", "variant": "primary" } + ], + "tagline": [{"en-US": "Speed is a choice"}, {"nl-NL": "Snelheid is een keuze"}], + "available_fields": ["tone", "voice_synthesis", "assets", "rights"] +} +``` + + + +```json Request (authorized, specific fields) +{ + "brand_id": "daan_janssen", + "fields": ["logos", "tone", "voice_synthesis"], + "use_case": "endorsement" +} +``` + +```json Response (authorized) +{ + "brand_id": "daan_janssen", + "house": { "domain": "lotientertainment.com", "name": "Loti Entertainment" }, + "names": [{"en": "Daan Janssen"}], + "logos": [ + { "url": "https://cdn.lotientertainment.com/janssen/headshot.jpg", "variant": "primary" }, + { "url": "https://assets.lotientertainment.com/janssen/hero_01_highres.jpg", "variant": "full-lockup", "width": 3000, "height": 2000 } + ], + "voice_synthesis": { + "provider": "elevenlabs", + "voice_id": "janssen_v2", + "settings": { "stability": 0.7 } + }, + "tone": { + "voice": "enthusiastic, warm, competitive", + "attributes": ["athletic", "Dutch pride", "approachable"], + "dos": ["Reference athletic achievements", "Use Dutch cultural touchpoints"], + "donts": ["No injury references", "No competitor comparisons"] + } +} +``` + + + +```json Request (creative production) +{ + "brand_id": "daan_janssen", + "fields": ["colors", "fonts", "visual_guidelines"], + "use_case": "creative_production" +} +``` + +```json Response (authorized) +{ + "brand_id": "daan_janssen", + "house": { "domain": "lotientertainment.com", "name": "Loti Entertainment" }, + "names": [{"en": "Daan Janssen"}], + "colors": { + "primary": "#FF6600", + "secondary": "#1A1A2E", + "accent": "#FBA007" + }, + "fonts": { + "primary": { + "family": "Montserrat", + "files": [ + { "url": "https://cdn.example.com/fonts/montserrat-vf.woff2", "weight_range": [100, 900], "style": "normal" } + ], + "opentype_features": ["tnum"], + "fallbacks": ["Helvetica Neue", "Arial", "sans-serif"] + }, + "secondary": "Open Sans" + }, + "visual_guidelines": { + "photography": { + "realism": "photorealistic", + "lighting": "bright, natural", + "framing": ["medium shot", "action shot"] + }, + "restrictions": [ + "Never place text over the athlete", + "No competitor brand logos in frame" + ] + } +} +``` + + + +```json Response (error) +{ + "errors": [ + { "code": "brand_not_found", "message": "No brand with id 'unknown_brand' in this agent's roster" } + ] +} +``` + + +## Parameters + +### Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `brand_id` | string | Yes | Brand identifier from the agent's brand.json brands array | +| `fields` | string[] | No | Optional sections to include (e.g., `logos`, `colors`, `fonts`, `visual_guidelines`, `tone`). Omit for all authorized sections. Core fields (`brand_id`, `house`, `names`) are always returned and do not need to be requested. | +| `use_case` | string | No | Intended use case (e.g., "endorsement", "voice_synthesis", "likeness"). Agent tailors content within the returned sections — a "likeness" use case returns action photos, a "voice_synthesis" use case returns voice configs. Does not override `fields`. | + +Valid `fields` values: `description`, `industries`, `keller_type`, `logos`, `colors`, `fonts`, `visual_guidelines`, `tone`, `tagline`, `voice_synthesis`, `assets`, `rights` + +Recommended `use_case` values: + +| Value | Agent behavior | +|-------|---------------| +| `endorsement` | Prioritize action photos, endorsement tone, brand story | +| `voice_synthesis` | Return voice synthesis config, pronunciation guides | +| `likeness` | High-res photos, appearance guidelines | +| `creative_production` | Full visual identity: colors, fonts, visual_guidelines, logos | +| `media_planning` | Basic identity and rights availability summary | + +`use_case` is advisory — it tailors content within returned sections but does not override `fields`. + +### Response + +The response mirrors the brand.json brand definition, extended with dynamic data the agent controls: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `brand_id` | string | Yes | Brand identifier | +| `house` | object | Yes | House (corporate entity): `domain` and `name` | +| `names` | object[] | Yes | Localized names | +| `description` | string | No | Brand description | +| `industries` | string[] | No | Industries or categories | +| `keller_type` | string | No | Brand architecture type: `master`, `sub_brand`, `endorsed`, `independent` | +| `logos` | object[] | No | Brand logos (matches brand.json logo shape: `url`, `variant`, `orientation`, `background`, `tags`) | +| `colors` | object | No | Brand color palette with structured roles (`primary`, `secondary`, `accent`, `background`, `text`) | +| `fonts` | object | No | Brand typography. Keys are role names (`primary`, `secondary`). Values are a CSS font-family string or an object with `family`, `files` (array of `{url, weight, weight_range, style}`), `opentype_features`, and `fallbacks` | +| `visual_guidelines` | object | No | Photography, graphic style, colorways, type scale, motion rules, restrictions | +| `tone` | object | No | Brand voice and messaging guidelines. Sub-fields: `voice` (personality adjectives), `attributes` (prompt guidance traits), `dos` (approved approaches), `donts` (prohibited topics) | +| `tagline` | string | No | Brand tagline or slogan | +| `voice_synthesis` | object | No | TTS voice synthesis configuration (`provider`, `voice_id`, `settings`) | +| `assets` | object[] | No | Available brand assets — matches brand.json asset shape (`asset_id`, `asset_type`, `url`, `tags`) | +| `rights` | object | No | Rights availability summary (for pricing, use `get_rights`) | +| `available_fields` | string[] | No | Sections available but not returned due to authorization level. Tells the caller what linking their account would unlock. | + +## Use cases + +- **DAM**: Returns high-res assets, current campaign guidelines, seasonal creative toolkits +- **Enterprise brand agent**: Returns approved copy, brand voice guidelines, current taglines +- **Rights management agent**: Returns talent identity — tone, voice synthesis, photos, rights availability + +## Next steps + + + + Search for licensable rights with pricing. + + + Acquire rights with contractual clearance. + + diff --git a/dist/docs/3.0.13/brand-protocol/tasks/get_rights.mdx b/dist/docs/3.0.13/brand-protocol/tasks/get_rights.mdx new file mode 100644 index 0000000000..5cf1f24315 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/tasks/get_rights.mdx @@ -0,0 +1,176 @@ +--- +title: get_rights +description: "get_rights is the AdCP task for discovering licensable talent, music, and stock media. Search with natural language, get matches with pricing options, and filter by buyer brand compatibility." +"og:title": "AdCP — get_rights" +testable: true +--- + + +**Experimental.** Brand rights lifecycle (`get_rights`, `acquire_rights`, `update_rights`) 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 any of these tasks MUST declare `brand.rights_lifecycle` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Search for licensable rights across a brand agent's roster. Returns matches with pricing options. Discovery is natural-language-first — no taxonomy, LLMs interpret intent from the query. + +## Schema + +- **Request**: [`get-rights-request.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/get-rights-request.json) +- **Response**: [`get-rights-response.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/get-rights-response.json) + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} + +## Response time + +Typically 2-10 seconds. The agent may perform compatibility checks against the buyer's brand.json, which adds latency. + +## Quick start + + +```json Request +{ + "query": "Dutch athlete for restaurant brand in Amsterdam, budget 400 EUR/month", + "uses": ["likeness", "voice"], + "buyer_brand": { "domain": "bistro-oranje.nl" }, + "include_excluded": true +} +``` + +```json Response +{ + "rights": [ + { + "rights_id": "janssen_likeness_voice", + "brand_id": "daan_janssen", + "name": "Daan Janssen", + "description": "Dutch Olympic speed skater, 2x gold medalist", + "right_type": "talent", + "match_score": 0.92, + "match_reasons": [ + "Available for food/restaurant brands in NL", + "Within budget at 350 EUR/month", + "Athletic brand aligns with Bistro Oranje's quality positioning" + ], + "available_uses": ["likeness", "voice", "name", "endorsement"], + "countries": ["NL", "BE", "DE"], + "pricing_options": [ + { + "pricing_option_id": "cpm_endorsement", + "model": "cpm", + "price": 3.50, + "currency": "EUR", + "uses": ["likeness"], + "description": "Per-impression royalty for AI-generated creatives using likeness" + }, + { + "pricing_option_id": "monthly_exclusive", + "model": "flat_rate", + "price": 350, + "currency": "EUR", + "period": "monthly", + "uses": ["likeness", "voice"], + "impression_cap": 100000, + "overage_cpm": 4.00, + "description": "Monthly exclusive license for likeness + voice, up to 100K impressions" + } + ], + "content_restrictions": ["approval_required"], + "preview_assets": [ + { "url": "https://cdn.lotientertainment.com/janssen/headshot.jpg", "usage": "preview_only" } + ] + } + ], + "excluded": [ + { + "brand_id": "pieter_van_dijk", + "name": "Pieter van Dijk", + "reason": "Dietary lifestyle conflict with steakhouse brand", + "suggestions": ["Available for plant-based and health food brands"] + } + ] +} +``` + + +## Parameters + +### Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `query` | string | Yes | Natural language description of desired rights. Include budget, geography, use case. | +| `uses` | string[] | Yes | Rights uses being requested (`likeness`, `voice`, `name`, `endorsement`, etc.) | +| `buyer_brand` | brand-ref | No | Buyer's brand. Agent fetches buyer's brand.json for compatibility filtering. | +| `countries` | string[] | No | Countries where rights are needed (ISO 3166-1 alpha-2) | +| `brand_id` | string | No | Search within a specific brand's rights | +| `right_type` | string | No | Filter by rights type (`talent`, `music`, `stock_media`, etc.) | +| `include_excluded` | boolean | No | Include filtered-out results in the `excluded` array with reasons. Defaults to false. | +| `pagination` | object | No | Pagination parameters for large result sets | + +### Response + +| Field | Type | Description | +|-------|------|-------------| +| `rights` | object[] | Matching rights with pricing, ranked by relevance | +| `rights[].rights_id` | string | Identifier for this offering — referenced in `acquire_rights` | +| `rights[].brand_id` | string | Brand identifier | +| `rights[].name` | string | Display name | +| `rights[].match_score` | number | Relevance score (0-1) | +| `rights[].match_reasons` | string[] | Why this result matches | +| `rights[].available_uses` | string[] | Rights uses available | +| `rights[].countries` | string[] | Countries where available | +| `rights[].excluded_countries` | string[] | Countries excluded from availability | +| `rights[].exclusivity_status` | object | Current exclusivity availability (`available`, `existing_exclusives`) | +| `rights[].pricing_options` | object[] | Pricing options (see below) | +| `rights[].description` | string | Description of the rights subject | +| `rights[].right_type` | string | Type of rights (`talent`, `music`, `stock_media`, etc.) | +| `rights[].content_restrictions` | string[] | Content restrictions or approval requirements | +| `rights[].preview_assets` | object[] | Preview-only assets for evaluation | +| `excluded` | object[] | Filtered results with reasons (only when `include_excluded: true`) | +| `excluded[].suggestions` | string[] | Actionable alternatives if the exclusion is fixable. Absent if the exclusion is final. | + +### Rights pricing options + +Pricing options are specific to rights — they include period, impression caps, overage rates, and use-type scoping: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `pricing_option_id` | string | Yes | Referenced in `acquire_rights` and `report_usage` | +| `model` | string | Yes | Pricing model (`cpm`, `flat_rate`, etc.) | +| `price` | number | Yes | Price amount | +| `currency` | string | Yes | ISO 4217 currency code | +| `uses` | string[] | Yes | Rights uses covered by this option | +| `period` | string | No | Billing period (`monthly`, `quarterly`, etc.) | +| `impression_cap` | integer | No | Max impressions included per period | +| `overage_cpm` | number | No | CPM for impressions exceeding the cap | + +## Composite rights + +Request multiple uses (e.g., `["likeness", "voice"]`) and the agent bundles them into a single pricing option. One call, one price. + +## Buyer brand filtering + +When `buyer_brand` is provided, the agent fetches the buyer's brand.json and uses it for compatibility filtering. For example, a vegetarian athlete would be excluded from steakhouse campaigns. Set `include_excluded: true` to see filtered results with reasons. + +## Music licensing and DDEX + +This section is for implementers building music rights agents. If you're a buyer using the brand protocol, the standard `get_rights` and `acquire_rights` flow works identically for music — you don't need to know DDEX. + +The rights protocol supports music licensing alongside talent rights. Music sync platforms implement `get_rights` with `right_type: "music"` and return pricing options for sync/background use. + +AdCP's rights model draws from the [DDEX](https://ddex.net/) Party Information Exchange (PIE) pattern — each `get_rights` response is a stateless snapshot of current availability rather than a delta against previous state. Key mappings for implementers familiar with DDEX: + +| AdCP concept | DDEX equivalent | Notes | +|---|---|---| +| `rights_id` | ISRC / ISWC | AdCP uses agent-scoped IDs; include standard identifiers in `ext` | +| `available_uses` | Use types (sync, background, etc.) | AdCP uses `right-use` enum values | +| `pricing_options` | License offers | Same concept, different structure | +| `content_restrictions` | Territorial/usage restrictions | AdCP is less granular than DDEX | +| `acquire_rights` | License grant | Returns generation credentials for AI music production | + +Music rights agents should include standard identifiers (ISRC, ISWC) in the `ext` field of rights responses for interoperability with existing music licensing systems. + +## Next steps + +After selecting a rights offering: +1. Call [`get_brand_identity`](/dist/docs/3.0.13/brand-protocol/tasks/get_brand_identity) for the selected brand's full identity data +2. Call [`acquire_rights`](/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights) with the `rights_id` and `pricing_option_id` diff --git a/dist/docs/3.0.13/brand-protocol/tasks/update_rights.mdx b/dist/docs/3.0.13/brand-protocol/tasks/update_rights.mdx new file mode 100644 index 0000000000..2aaeb044c0 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/tasks/update_rights.mdx @@ -0,0 +1,148 @@ +--- +title: update_rights +description: "update_rights is the AdCP task for modifying active rights grants. Extend end dates, adjust impression caps, switch pricing options, or pause and resume — with re-issued generation credentials." +"og:title": "AdCP — update_rights" +testable: true +--- + + +**Experimental.** Brand rights lifecycle (`get_rights`, `acquire_rights`, `update_rights`) 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 any of these tasks MUST declare `brand.rights_lifecycle` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Modify an existing rights grant — extend dates, adjust impression caps, change pricing, or pause/resume. Parallels `update_media_buy`. Only the fields you provide are updated; omitted fields remain unchanged. + +## Schema + +- **Request**: [`update-rights-request.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/update-rights-request.json) +- **Response**: [`update-rights-response.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/update-rights-response.json) + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} + +## Response time + +Seconds for most updates. Pricing changes may require rights holder approval and take longer. + +## Quick start + + +```json Request (extend end date) +{ + "rights_id": "janssen_likeness_voice", + "end_date": "2026-09-30" +} +``` + +```json Request (increase impression cap) +{ + "rights_id": "janssen_likeness_voice", + "impression_cap": 200000 +} +``` + +```json Request (pause grant) +{ + "rights_id": "janssen_likeness_voice", + "paused": true +} +``` + +```json Response (error) +{ + "errors": [ + { + "code": "invalid_update", + "message": "New impression_cap (50000) must be >= impressions already delivered (78432)" + } + ] +} +``` + +```json Response (success) +{ + "rights_id": "janssen_likeness_voice", + "terms": { + "pricing_option_id": "monthly_exclusive", + "amount": 350, + "currency": "EUR", + "period": "monthly", + "uses": ["likeness", "voice"], + "impression_cap": 200000, + "overage_cpm": 4.00, + "start_date": "2026-04-01", + "end_date": "2026-09-30", + "exclusivity": { + "scope": "Exclusive licensee for Daan Janssen in NL for food/restaurant brands", + "countries": ["NL"] + } + }, + "generation_credentials": [ + { + "provider": "midjourney", + "rights_key": "rk_mj_abc123_renewed...", + "uses": ["likeness"], + "expires_at": "2026-09-30T23:59:59Z" + }, + { + "provider": "elevenlabs", + "rights_key": "rk_el_def456_renewed...", + "uses": ["voice"], + "expires_at": "2026-09-30T23:59:59Z" + } + ], + "rights_constraint": { + "rights_id": "janssen_likeness_voice", + "rights_agent": { "url": "https://agent.lotientertainment.com/mcp", "id": "loti_entertainment" }, + "valid_from": "2026-04-01T00:00:00Z", + "valid_until": "2026-09-30T23:59:59Z", + "uses": ["likeness", "voice"], + "countries": ["NL"], + "impression_cap": 200000, + "approval_status": "approved", + "verification_url": "https://agent.lotientertainment.com/rights/rts_abc123/verify" + }, + "implementation_date": "2026-06-28T14:30:00Z" +} +``` + + +## Parameters + +### Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `rights_id` | string | Yes | Rights grant identifier from `acquire_rights` | +| `end_date` | date | No | New end date (must be >= current end date) | +| `impression_cap` | integer | No | New impression cap (must be >= impressions already delivered) | +| `pricing_option_id` | string | No | Switch to a different pricing option from the original `get_rights` offering | +| `paused` | boolean | No | Pause (`true`) or resume (`false`) the grant | +| `idempotency_key` | string | No | Client-generated key for safe retries | + +### Response + +| Field | Type | Description | +|-------|------|-------------| +| `rights_id` | string | The updated rights grant identifier | +| `terms` | object | Updated contractual terms (same shape as `acquire_rights` acquired response) | +| `generation_credentials` | array | Re-issued credentials with updated expiration and caps | +| `rights_constraint` | object | Updated constraint for re-embedding in creative manifests | +| `paused` | boolean | Whether the grant is currently paused (included when pause state changes) | +| `implementation_date` | datetime\|null | When changes take effect (`null` if pending approval) | + +## Re-issued credentials + +When you extend dates or change pricing, the rights agent re-issues generation credentials with updated expiration. Both old and new credentials may work during an overlap period — old credentials remain valid until their original expiry. Replace credentials in your creative pipeline promptly, but there is no hard cutover moment that would break in-flight generation requests. + +The updated `rights_constraint` should replace the constraint in any active creative manifests so downstream systems see the current terms. + +## Next steps + + + + The initial rights acquisition flow. + + + Report impressions against rights grants. + + diff --git a/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim.mdx b/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim.mdx new file mode 100644 index 0000000000..d51ef3865a --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim.mdx @@ -0,0 +1,392 @@ +--- +title: verify_brand_claim +description: "verify_brand_claim is the unified AdCP brand-protocol task for asking a brand-agent whether a specific claim about its identity is owned, pending, disputed, or licensed. One tool, four claim types — subsidiary, parent, property, trademark — discriminated by a claim_type field." +"og:title": "AdCP — verify_brand_claim" +testable: true +--- + +Ask a brand-agent a verification question about a facet of its identity. **This is a prerequisite gate — check before you proceed, not a signal you consume after a decision is locked in.** One tool with four claim types covers the verification dimensions: + +| `claim_type` | The question | Used at | +|---|---|---| +| `subsidiary` | "Is this brand a subsidiary of yours?" | Brand-relationship establishment, member-feature provisioning, governance-trust extension | +| `parent` | "Is this brand your parent house?" (leaf-side mirror) | Mutual-assertion confirmation at the agent layer | +| `property` | "Is this site / app / property one of yours?" | Inventory onboarding, creative clearance, fraud escalation | +| `trademark` | "Is this trademark yours?" | Creative-clearance gates, licensee-posture confirmation | + +The brand-agent answers using its own data — which is brand.json plus the richer states (`pending_review`, `transferring`, `licensed_in`, etc.) that the static file can't express. The tool is one specific-question affordance on top of the same identity surface `get_brand_identity` reads. + +For high-volume verification (portfolio refresh, creative-clearance batches, crawler scans), use the bulk variant [`verify_brand_claims`](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claims) — same per-claim semantics, one round-trip and one rate-limit slot for the whole batch. + +## The trust rule — two calls, not one + +**A single signed `owned` response is NOT trust-extending. Mutual assertion remains the floor for positive trust.** This is the load-bearing rule of the asymmetric trust model — assertion direction requires both sides to agree. Consumers MUST call both sides when extending relationship trust: + +- For `subsidiary` claims, also call the leaf's brand-agent with `claim_type: "parent"` (or crawl the leaf's `brand.json` for its `house_domain`). +- For `property` claims, cross-check against the brand's static `brand.json` `properties[]` and (for domains) DNS/TLS evidence. +- For `trademark` claims, cross-check the public registry record. For `licensed_in` specifically, the licensor named in `details.licensor_domain` SHOULD reciprocate `licensed_out` for the same mark before the licensing relationship is trusted. +- **Only rejections (`disputed` / `not_ours`) are authoritative on a single signed response** — a brand has standing to refuse association unilaterally. + +Shortcuts kill the trust model. Without the reciprocation step, a malicious or mistaken house could claim subsidiaries, properties, or licensed marks it doesn't actually have. See [`brand.json` § Agent-augmented verification](/dist/docs/3.0.13/brand-protocol/brand-json#agent-augmented-verification) for the full normative trust table and the malicious-house walkthrough. + +## Schema + +- **Request**: [`verify-brand-claim-request.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/verify-brand-claim-request.json) +- **Response**: [`verify-brand-claim-response.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/verify-brand-claim-response.json) + +## Capability discovery + +The brand-agent advertises this task in its `get_adcp_capabilities` response. An agent that supports only some claim types declares this via the per-tool capability extension: + +```json +{ + "supported_protocols": ["brand"], + "supported_tasks": [ + "get_brand_identity", + "verify_brand_claim" + ], + "brand": { + "verify_brand_claim": { + "supported_claim_types": ["subsidiary", "parent", "trademark"] + } + } +} +``` + +When `supported_claim_types` is omitted, the agent advertises support for all four. Consumers MUST check before relying on a specific claim type; unsupported types return `UNSUPPORTED_CLAIM_TYPE` (see [Error handling](#error-handling)). + +### Minimum viable adoption + +A brand-agent doesn't have to ship all four claim types at once. Pick the slice that matches the workflow: + +- **Property only** — creative-clearance and inventory-onboarding consumers; the smallest useful surface. +- **Subsidiary + parent** — partners doing brand-relationship establishment or governance-trust extension; ship both halves at once so mutual assertion completes at the agent layer. +- **Trademark only** — creative-clearance pipelines that need licensee posture (the differentiator from registry crawls). +- **All four** — full coverage; recommended for AAO-hosted agents serving many member configurations. + +Advertise only the types you implement. Partners check `supported_claim_types` and route accordingly; unsupported types return `UNSUPPORTED_CLAIM_TYPE` cleanly. + +## Authorization tiers + +The public/authorized split per claim type: + +| Tier | What the agent returns | +|---|---| +| **Public** (no linked account) | `claim_type`, `status` (always). For applicable claim types: `details.brand_id`, `details.relationship`, `details.matched_registration`, `details.countries`, `details.nice_classes`. `context_note` when populated. | +| **Authorized** (via [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts)) | Everything above, plus: `details.first_observed_by_house_at`, `details.expected_resolution_window_days`, `details.use_case_authorization`, `details.licensor_domain` (when licensed_in). | + +Queue position, internal ticket state, and team routing are never exposed. + +## Per-claim-type request and response shapes + +The `details` field on the response varies by `claim_type`. Below: the request payload and the typed `details` fields each claim type returns. + +### `claim_type: "subsidiary"` + +House-side: a consumer detects `converse.com` claiming `house_domain: nikeinc.com`. Asks Nike's agent: + +```json +{ + "claim_type": "subsidiary", + "claim": { + "subsidiary_domain": "converse.com", + "subsidiary_brand_id": "converse", + "observed_at": "2026-05-14T10:00:00Z" + } +} +``` + +```json +{ + "claim_type": "subsidiary", + "verification_status": "owned", + "details": { "brand_id": "converse" } +} +``` + +The brand can also reject — rejection direction is authoritative on a single signed response, no reciprocation required: + +```json +{ + "claim_type": "subsidiary", + "verification_status": "not_ours", + "context_note": "We have no record of this brand; the leaf's claim is in error." +} +``` + +Applicable statuses: `owned`, `pending_review`, `transferring`, `disputed`, `not_ours`, `archived`, `unknown`. (`licensed_in` / `licensed_out` don't apply — subsidiaries aren't licensed; brands and trademarks are.) `archived` means the brand once held this subsidiary (e.g., a divested business unit) but no longer does — distinct from `not_ours` (never owned). + +**Request `claim` fields:** + +| Field | Required | Notes | +|---|---|---| +| `subsidiary_domain` | Yes | Domain of the leaf brand whose `house_domain` claim is being verified. | +| `subsidiary_brand_id` | No | Stable brand identifier the leaf uses for itself. Recommended; helps the agent disambiguate when multiple brands share a domain. | +| `observed_at` | No | ISO 8601 timestamp — when the caller observed the leaf's claim. Helps the agent age claims and prioritize fresh ones in its internal queue. | + +**Response `details` fields:** + +| Field | Tier | Returned when | Notes | +|---|---|---|---| +| `brand_id` | Public | status ∈ {`owned`, `pending_review`, `transferring`} | The house's brand_id for this subsidiary. | +| `first_observed_by_house_at` | Authorized | any | When the house first became aware of the claim. | +| `expected_resolution_window_days` | Authorized | **REQUIRED** when status is `pending_review`; otherwise Authorized when present | The aging window. Enforcement is agent-side: the agent MUST transition the claim to a terminal status or `unknown` once the window elapses. When a `pending_review` response is older than its declared window, consumers SHOULD treat it as `unknown` and fall back to crawl. | + +### `claim_type: "parent"` (leaf-side mirror) + +Leaf-side: a consumer wants the leaf's authoritative answer about its parent. Asks Converse's agent: + +```json +{ + "claim_type": "parent", + "claim": { + "parent_domain": "nikeinc.com", + "claimant_says": "Nike's brand_refs[] lists converse.com" + } +} +``` + +```json +{ + "claim_type": "parent", + "verification_status": "owned", + "details": { "house_domain": "nikeinc.com" } +} +``` + +The leaf can also actively reject: + +```json +{ + "claim_type": "parent", + "verification_status": "disputed", + "context_note": "We are not a Nike subsidiary; their claim is in error." +} +``` + +Applicable statuses: same as `subsidiary` (mirror). + +**Request `claim` fields:** + +| Field | Required | Notes | +|---|---|---| +| `parent_domain` | Yes | Domain of the house being claimed as this brand's parent. | +| `claimant_says` | No | Free-text context about what the claimant published (e.g., "Nike's brand_refs[] lists converse.com"). Helps the agent disambiguate competing claims. | +| `observed_at` | No | ISO 8601 timestamp — when the caller observed the parent claim. | + +**Response `details` fields:** + +| Field | Tier | Returned when | Notes | +|---|---|---|---| +| `house_domain` | Public | status ∈ {`owned`, `transferring`} | The brand's declared parent house. NOT returned for `pending_review` — the leaf hasn't yet accepted the parent claim. | +| `first_observed_by_leaf_at` | Authorized | any | When the leaf first became aware of a third-party claim about its parentage. | + +When both `verify_brand_claim` with `claim_type: "subsidiary"` (on the house) AND with `claim_type: "parent"` (on the leaf) return `owned` for the same relationship, **mutual assertion is established at the agent layer** — no static-file crawl required. This is the cleanest path for trust extension. + +### `claim_type: "property"` + +The request asks about one property; the response describes the brand's relationship with that property, including all regions where the relationship applies (which may exceed the one named in the query). + +```json +{ + "claim_type": "property", + "claim": { + "property": { + "type": "website", + "identifier": "nike.cn", + "region": "CN" + }, + "use_case": "advertising" + } +} +``` + +```json +{ + "claim_type": "property", + "verification_status": "owned", + "details": { + "relationship": "owned", + "brand_id": "nike", + "regions": ["CN"], + "use_case_authorization": { "advertising": true } + }, + "context_note": "Regional site for China market" +} +``` + +A property that spans multiple regions (e.g., a global e-commerce surface) returns all of them: + +```json +{ + "claim_type": "property", + "claim": { "property": { "type": "website", "identifier": "nike.com" } } +} +→ +{ + "claim_type": "property", + "verification_status": "owned", + "details": { + "relationship": "owned", + "brand_id": "nike", + "regions": ["US", "CA", "GB", "FR", "DE", "JP", "AU"] + } +} +``` + +The brand can also reject — rejection direction is authoritative on a single signed response: + +```json +{ + "claim_type": "property", + "claim": { "property": { "type": "website", "identifier": "fake-nike-store.com" } } +} +→ +{ + "claim_type": "property", + "verification_status": "not_ours", + "context_note": "Unaffiliated third-party site; we do not authorize use of our marks on it." +} +``` + +Applicable statuses: `owned`, `transferring`, `disputed`, `not_ours`, `archived`, `unknown`. (`pending_review` is uncommon for properties; use `transferring` for in-flight ownership changes.) `archived` means the brand once operated this property (e.g., a sold-off domain) but no longer does. + +**Request `claim` fields:** + +| Field | Required | Notes | +|---|---|---| +| `property.type` | Yes | `website`, `mobile_app`, `ctv_app`, `desktop_app`, `dooh`, `podcast`, `radio`, `streaming_audio`. | +| `property.identifier` | Yes | Domain for websites/podcasts, bundle id for apps, etc. | +| `property.store` | When type is an app | `apple`, `google`, `amazon`, `roku`, `fire_tv`, `samsung`, `lg`, `vizio`, `other`. | +| `property.region` | No | Single ISO 3166-1 alpha-2 code (or `"global"`) — the region the caller cares about. The response's `details.regions` carries the full applicable set. | +| `use_case` | No | Free-text use case (e.g., `"advertising"`). The agent MAY scope its answer accordingly. | + +**Response `details` fields:** + +| Field | Tier | Returned when | Notes | +|---|---|---|---| +| `relationship` | Public | status ∈ {`owned`, `transferring`} | `owned` / `direct` / `delegated` / `ad_network` — mirrors brand.json's `properties[].relationship`. | +| `brand_id` | Public | status ∈ {`owned`, `transferring`} | The brand within the house that owns this property. | +| `regions` | Public | status ∈ {`owned`, `transferring`} | ISO 3166-1 alpha-2 codes, or `["global"]` sentinel for no regional restriction. May include regions beyond the one the request named. | +| `use_case_authorization` | Authorized | any | Per-use-case permission map. Registered keys: `advertising`, `endorsement`, `retail_listing`, `editorial`, `commercial_advertising`, `merchandise_resale`. Agents MAY add extensions. | + +### `claim_type: "trademark"` + +```json +{ + "claim_type": "trademark", + "claim": { + "mark": "AIR JORDAN", + "registry": "USPTO", + "number": "1234567" + } +} +``` + +```json +{ + "claim_type": "trademark", + "verification_status": "owned", + "details": { + "matched_registration": { + "registry": "USPTO", + "number": "1234567", + "mark": "AIR JORDAN", + "registration_status": "active" + }, + "countries": ["US"], + "nice_classes": [25, 41] + } +} +``` + +```json +{ + "claim_type": "trademark", + "verification_status": "licensed_in", + "details": { + "matched_registration": { "registry": "EUIPO", "number": "EU98765", "mark": "CONVERSE", "registration_status": "active" }, + "licensor_domain": "converseholdings-eu.com", + "countries": ["FR", "DE", "IT", "ES"], + "nice_classes": [25] + } +} +``` + +**`licensed_in` reciprocation.** Consumers SHOULD treat `licensed_in` as **unverified** until the named `licensor_domain`'s brand-agent reciprocates `licensed_out` for the same mark (same mutual-assertion shape as ownership, just across the licensing edge). Without reciprocation, the brand could unilaterally claim a licensed relationship that doesn't exist. + +The brand can also reject — rejection direction is authoritative on a single signed response: + +```json +{ + "claim_type": "trademark", + "claim": { "mark": "AIR JORDAN", "registry": "EUIPO" } +} +→ +{ + "claim_type": "trademark", + "verification_status": "disputed", + "context_note": "EU mark in this jurisdiction held by separate entity; we contest their registration and do not authorize use as ours." +} +``` + +Applicable statuses: `owned`, `licensed_in`, `licensed_out`, `transferring`, `disputed`, `not_ours`, `archived`, `unknown`. (`pending_review` is uncommon — trademark registrations are public-record events with definitive ownership at any given time.) `archived` means the brand once held this mark (expired, cancelled, transferred to another party) but no longer does. + +**`details` fields:** + +| Field | Tier | Notes | +|---|---|---| +| `matched_registration` | Public | The registration the agent matched the query to. Returned when status is `owned`, `licensed_in`, `licensed_out`, or `transferring`. | +| `licensor_domain` | Public | Domain of the licensor when status is `licensed_in`. | +| `countries` | Public | ISO 3166-1 alpha-2 codes the response covers. | +| `nice_classes` | Public | Nice Classification class numbers. Disambiguates cross-industry marks. | +| `use_case_authorization` | Authorized | Per-use-case permission for this mark — the differentiator from registry crawls. | + +## Trust model + +The agent's response is signed under the brand's `adcp_use: "response-signing"` JWK. This is a payload-envelope JWS — the signature lives inside the response body, distinct from RFC 9421 §2.2.9 transport response signing (which AdCP 3.x does not define). `verify_brand_claim` is on the spec's [designated-task response-signing list](/dist/docs/3.0.13/building/by-layer/L1/security#designated-task-response-signing); response signing on tasks outside that list is forbidden. + +The signature attests authorship of the response payload under the brand's published key at signing time; it is not a non-repudiation receipt and does not bind the brand to assertions beyond the direction-asymmetric semantics below. Replay protection ([#4716](https://github.com/adcontextprotocol/adcp/issues/4716)), per-brand JWK uniqueness ([#4717](https://github.com/adcontextprotocol/adcp/issues/4717)), and tenant binding on the envelope ([#4718](https://github.com/adcontextprotocol/adcp/issues/4718)) are tracked for 3.2 hardening — until then, verifiers SHOULD treat captured signed responses as freshness-unbound. + +The trust model is **direction-asymmetric**: + +- **Rejection direction** (agent says `disputed` / `not_ours`) is authoritative. A brand can refuse association unilaterally; no mutual reciprocation required. +- **Assertion direction** (agent says `owned` / `pending_review` / `transferring` / `licensed_*`) is informative but NOT trust-extending on its own. The reciprocating side must still confirm before trust extends. + +When both the house-side and leaf-side agents speak (via `claim_type: "subsidiary"` and `claim_type: "parent"` respectively), **mutual assertion is established at the agent layer**. When only one side has an agent, fall back to crawl-based mutual-assertion inference per [`brand.json` § Mutual-assertion trust model](/dist/docs/3.0.13/brand-protocol/brand-json#mutual-assertion-trust-model). + +See [`brand.json` § Agent-augmented verification](/dist/docs/3.0.13/brand-protocol/brand-json#agent-augmented-verification) for the full trust table and the malicious-house walkthrough. + +## Caching + +Per-status: + +- `owned` / `not_ours` / `disputed` — stable. 24-72h. +- `pending_review` — volatile. Max-age ≤1h. +- `transferring` — volatile until transition. Max-age ≤4h. +- `licensed_in` / `licensed_out` — moderately volatile. 24h. +- `use_case_authorization` — most volatile. Re-check per session. +- `unknown` — short cache (≤1h). + +Agents SHOULD set `Cache-Control: max-age=N`. Consumers MAY override downward but SHOULD NOT exceed agent-supplied `max-age`. + +## Error handling + +| Error code | Cause | +|---|---| +| `AUTH_INVALID` | Caller's signed envelope did not verify. | +| `RATE_LIMITED` | Agent has rate-limited the caller per `{caller_identity, claim_type, claim-target}`. Agents SHOULD return `Retry-After` and prefer returning cached prior answer. | +| `UNSUPPORTED_CLAIM_TYPE` | The agent does not implement the requested `claim_type`. Check `supported_claim_types` via `get_adcp_capabilities`. | +| `INVALID_INPUT` | Required `claim` fields are missing or malformed (e.g., `subsidiary_domain` not a valid hostname). | +| `AMBIGUOUS_MATCH` | For `claim_type: "trademark"` — multiple registrations match. Narrow with `registry`, `number`, or `countries`. | + +```json +{ + "errors": [ + { + "code": "UNSUPPORTED_CLAIM_TYPE", + "message": "claim_type 'property' is not supported by this agent. Supported: subsidiary, parent, trademark." + } + ] +} +``` diff --git a/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claims.mdx b/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claims.mdx new file mode 100644 index 0000000000..257116f958 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claims.mdx @@ -0,0 +1,159 @@ +--- +title: verify_brand_claims +description: "verify_brand_claims is the bulk variant of verify_brand_claim — verify many claims against one brand-agent in a single round-trip. Same four claim types (subsidiary, parent, property, trademark); results are returned positionally aligned with the request." +"og:title": "AdCP — verify_brand_claims" +testable: true +--- + +Bulk variant of [`verify_brand_claim`](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim). The agent answers many verification questions in a single round-trip, returning one result per claim in the same order the caller sent them. Use when MCP round-trip cost dominates the per-claim work — crawlers refreshing brand portfolios, creative-clearance pipelines clearing a creative batch against one rights-holder, inventory-onboarding scans verifying a supply-path against a house. + +**This is a bulk affordance, not a different operation.** Per-claim semantics — trust model, applicable statuses, authorization tiers, per-claim-type `details` shapes — are identical to the single-target tool. Everything documented on the single-target page applies per-result; this page covers only the bulk-specific concerns. + +## When to use the bulk variant + +| Workflow | Variant | Why | +|---|---|---| +| One-off verification (single page, single creative, single subsidiary check) | [`verify_brand_claim`](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim) | No batching benefit; simpler error model. | +| Portfolio refresh (re-verify all known subsidiaries / properties / marks for one brand) | `verify_brand_claims` | MCP overhead dominates. | +| Crawler-driven full-portfolio verification (Nike portfolio: ~100 properties across regions) | `verify_brand_claims` | 100 → 1 round-trip. | +| Creative-clearance batch (clear N creatives against one rights-holder pre-flight) | `verify_brand_claims` | Same rate-limit slot for the batch (see below). | +| Cross-brand verification (different claims against different agents) | One bulk call per brand-agent | Each agent is a separate addressable endpoint; bulk is intra-agent. | + +## Schema + +- **Request**: [`verify-brand-claims-request.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/verify-brand-claims-request.json) +- **Response**: [`verify-brand-claims-response.json`](https://adcontextprotocol.org/schemas/3.0.13/brand/verify-brand-claims-response.json) + +## Capability discovery + +Bulk support is advertised separately from the single-target tool. Agents MAY ship the single-target tool only, the bulk tool only, or both. A `supported_claim_types` declaration applies to BOTH tools when both are advertised — the bulk variant cannot accept claim types the agent's single-target implementation cannot answer. + +```json +{ + "supported_protocols": ["brand"], + "supported_tasks": [ + "get_brand_identity", + "verify_brand_claim", + "verify_brand_claims" + ], + "brand": { + "verify_brand_claim": { + "supported_claim_types": ["subsidiary", "parent", "property", "trademark"] + } + } +} +``` + +Agents MAY advertise a lower per-call batch ceiling than the spec's 100 cap. When the ceiling differs, advertise it via an `extensions`-style entry on the capability descriptor or document it out-of-band; consumers SHOULD treat 100 as the maximum and SHOULD chunk accordingly when the agent's cap is lower. + +## Order is preserved + +The agent MUST return `results[]` in the same order as the request's `claims[]` (positional zip-by-index). Callers pass a position-indexed batch and consume results by index. This guarantee lets callers correlate inputs and outputs without re-keying: + +``` +request.claims[7] ↔ response.results[7] +``` + +If the batch fails wholesale (auth, rate-limit, malformed request), the response carries top-level `errors[]` and omits `results`. There is no "partial response with results AND batch errors" mode — batch errors and per-result errors are mutually exclusive at the wire. + +## Partial failure — per-result errors + +Per-claim failures (an `UNSUPPORTED_CLAIM_TYPE` for one of many claims, an `AMBIGUOUS_MATCH` on one trademark query in a portfolio of properties) DO NOT fail the batch. The corresponding entry in `results[]` carries an `error` field instead of `claim_type`/`status`, and the rest of the batch is unaffected: + +```json +{ + "results": [ + { "claim_type": "property", "status": "owned", "details": { "regions": ["US"] } }, + { "error": { "code": "AMBIGUOUS_MATCH", "message": "Multiple registrations match 'CONVERSE'; narrow with registry+number." } }, + { "claim_type": "subsidiary", "status": "not_ours" } + ] +} +``` + +Batch-level errors are reserved for failures that prevent the agent from answering anything: + +| Tier | Where it goes | Example codes | +|---|---|---| +| Per-result | `results[i].error` | `UNSUPPORTED_CLAIM_TYPE`, `INVALID_INPUT` for one item, `AMBIGUOUS_MATCH` | +| Batch-level | top-level `errors[]` | `AUTH_INVALID`, `RATE_LIMITED`, malformed request, `claims[]` over the cap | + +Full error-code semantics are documented on the [single-target task page § Error handling](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim#error-handling). + +## Caching + +Each result MAY carry its own staleness — `pending_review` is short-lived (≤1h), `owned` is stable (24-72h). Per-status caching guidance is per the single-target page. + +Top-level `Cache-Control: max-age` on the bulk response represents the **lowest-common max-age across the batch**: a batch with one `pending_review` and 99 `owned` results SHOULD cache at the `pending_review` ceiling, because consumer-side cache invalidation typically operates at response granularity. Callers that need per-result staleness should either split batches by expected status volatility, or re-verify volatile claims individually. + +Agents that support per-result cache hints MAY surface them via `ext` (e.g., `results[i].ext.cache.max_age_seconds`); this remains an extension surface, not part of the normative response. + +## Rate-limiting + +A bulk call consumes a **single rate-limit slot** per-call, not per-result. A batch of 100 claims hits the per-`{caller, query-target}` limit once, not 100 times. This is the core economic argument for the bulk variant — the limit is on the round-trip, not on the verification work. + +Implications: + +- Agents SHOULD size rate limits in calls/window rather than claims/window when bulk is advertised. If claim-volume matters operationally, advertise a per-batch claim cap (see Capability discovery). +- Callers SHOULD prefer one bulk call over N single calls when verifying against the same agent — both for cost and for staying inside the limit. +- A `RATE_LIMITED` response on a bulk call is a batch-level error; the whole batch is rejected. Retry with `Retry-After` honoured. + +## Trust model + +Identical to the single-target tool. Per-result `status` follows the same direction-asymmetric rules: rejections (`disputed` / `not_ours`) are authoritative on a single signed response; assertions (`owned` / `pending_review` / `transferring` / `licensed_*`) require reciprocation before extending positive trust. + +**No "single-call mutual assertion" shortcut.** A bulk call against one agent does NOT establish mutual assertion for the assertion-direction claims in that batch, even if both halves of a relationship pair appear inside the same bulk request. Mutual assertion is a property of two parties agreeing — the leaf-side agent still has to be called separately for any `subsidiary` claim that returns `owned`, the licensor's agent still has to be called for any `licensed_in`, and so on. Batching is about MCP-round-trip economics, not about collapsing the trust model. + +See [`brand.json` § Agent-augmented verification](/dist/docs/3.0.13/brand-protocol/brand-json#agent-augmented-verification) for the full trust table. + +## Example — portfolio refresh + +A crawler refreshes a known Nike portfolio (one subsidiary check + three property checks) in one round-trip: + +```json +{ + "claims": [ + { + "claim_type": "subsidiary", + "claim": { "subsidiary_domain": "converse.com", "subsidiary_brand_id": "converse" } + }, + { + "claim_type": "property", + "claim": { "property": { "type": "website", "identifier": "nike.com" } } + }, + { + "claim_type": "property", + "claim": { "property": { "type": "website", "identifier": "nike.cn", "region": "CN" } } + }, + { + "claim_type": "trademark", + "claim": { "mark": "AIR JORDAN", "registry": "USPTO", "number": "1234567" } + } + ] +} +``` + +```json +{ + "results": [ + { "claim_type": "subsidiary", "status": "owned", "details": { "brand_id": "converse" } }, + { "claim_type": "property", "status": "owned", "details": { "relationship": "owned", "brand_id": "nike", "regions": ["US", "CA", "GB", "FR", "DE", "JP", "AU"] } }, + { "claim_type": "property", "status": "owned", "details": { "relationship": "owned", "brand_id": "nike", "regions": ["CN"] }, "context_note": "Regional site for China market" }, + { "claim_type": "trademark", "status": "owned", "details": { "matched_registration": { "registry": "USPTO", "number": "1234567", "mark": "AIR JORDAN", "registration_status": "active" }, "countries": ["US"], "nice_classes": [25, 41] } } + ] +} +``` + +To extend governance trust through the `subsidiary` result, the caller still needs to call Converse's brand-agent with `claim_type: "parent"`. The bulk call is round-trip economy; it is not a trust-model shortcut. + +## Batch-level error example + +```json +{ + "errors": [ + { + "code": "RATE_LIMITED", + "message": "Caller has exhausted per-window quota. Retry after the indicated interval." + } + ] +} +``` diff --git a/dist/docs/3.0.13/brand-protocol/ui-guidance.mdx b/dist/docs/3.0.13/brand-protocol/ui-guidance.mdx new file mode 100644 index 0000000000..b4daa79b9d --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/ui-guidance.mdx @@ -0,0 +1,163 @@ +--- +title: UI guidance for rejected claims +description: "Non-normative consumer-side guidance for rendering verify_brand_claim disputed / not_ours rejections in DSPs, portfolio explorers, creative-clearance UIs, and brand-safety pipelines. Covers attribution language, recovery paths for the rejected publisher, audit trail recommendations, and legal-exposure considerations." +"og:title": "AdCP — UI guidance for rejected claims" +--- + +When [`verify_brand_claim`](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim) returns `disputed` or `not_ours`, the rejection is authoritative — a brand has standing to refuse association unilaterally. But the *signed answer* travels alone; the consumer surface that renders it owes the humans on both sides a sane presentation. + +This page is non-normative. It collects conventions for the consumer side — DSPs, portfolio explorers, creative-clearance UIs, brand-safety pipelines — and for the leaf publisher whose claim was rejected. + +## Rendering rejected claims + +The rejected response carries a `status` (`disputed` or `not_ours`) and usually a `context_note` written by the brand-agent. The note is intended for human eyes; surface it. + +### Attribution language + +**Always attribute the rejection to the rejecting brand.** A signed `not_ours` from Nike's brand-agent says *Nike says this is not theirs* — it does NOT say *this publisher is fake*. The distinction matters legally; see [Legal exposure](#legal-exposure) below. + +| Don't render | Render instead | +|---|---| +| "fake-nike-store.com is fraudulent" | "Nike, Inc. does not recognize fake-nike-store.com as one of its properties" | +| "This subsidiary claim is invalid" | "Nike, Inc. has rejected this brand's subsidiary claim" | +| "Trademark not owned" | "Brand X disputes the claim that this mark is theirs in this jurisdiction" | + +### DSP inventory shopping + +When a DSP buyer agent checks a property claim before bidding and gets `not_ours` or `disputed`: + +- **Exclude the property from the buy by default.** A rejected property claim means the publisher's house attribution is in question — bid risk is elevated regardless of the underlying inventory quality. +- **Surface the rejection inline in the inventory row**, not in a separate audit log. The buyer needs the signal at decision time. +- **Show the `context_note` verbatim** as the brand's explanation. Don't paraphrase — the brand wrote it deliberately. +- **Offer the buyer a manual-override path** for cases where the property is legitimately operated under a different relationship the buyer has separately verified. + +```text +┌─────────────────────────────────────────────────────────────┐ +│ ◯ premium-sports-network.com CPM $4.20 ━━━ │ +│ │ +│ ⚠ Nike, Inc. has rejected this site's house-affiliation │ +│ claim ("Unaffiliated third-party site; we do not │ +│ authorize use of our marks on it.") │ +│ │ +│ [View details] [Override and bid anyway] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Portfolio explorer + +A portfolio explorer (the AAO registry, a brand-safety vendor's lookup tool) renders brand relationships. When a relationship edge has been rejected at the agent layer: + +- **Don't show the edge as if it exists with a warning icon.** Show it as *contested* — a distinct visual state from *verified*, *unverified-pending-reciprocation*, and *missing*. +- **Render both sides of the rejected edge.** "Brand X claims subsidiary-of Brand Y. Brand Y has rejected this claim." +- **Timestamp the rejection.** Brands change positions; a 2-year-old rejection of what is now a real acquisition shouldn't dominate the UI. +- **Link the leaf to its update path** (see [Recovery paths](#recovery-paths-for-the-rejected-leaf) below). + +### Creative-clearance UI + +Creative-clearance pipelines call `verify_brand_claim` with `claim_type: "trademark"` to confirm a generated creative isn't trespassing on a mark. When the response is `disputed` or `not_ours`: + +- **Block the creative from publish by default.** The mark is contested or denied; promoting the creative invites takedown. +- **Render the disputing brand's `context_note`** so the creative reviewer can decide whether to escalate to legal. +- **Don't auto-retry** with a different mark variant — the rejection is jurisdictional and a near-miss may produce the same result. + +### Brand-safety pipeline + +Brand-safety vendors aggregate `verify_brand_claim` signals across the supply chain. When a property is rejected by its claimed house: + +- **Demote the property in safety scoring** but don't silently zero it out — the publisher may have legitimate independent inventory. +- **Surface the rejection in the safety report** with full attribution: "Brand X rejected this site's affiliation claim on $DATE." Buyers reading the report need to know who made the call. +- **Recompute on a schedule** matching the agent's `Cache-Control: max-age`. Don't pin the rejection state forever — the underlying status can transition. + +## Recovery paths for the rejected leaf + +The leaf publisher whose `house_domain` (or `properties[]`, or `trademarks[]`) claim was rejected has **no protocol-level recourse beyond updating or removing the claim**. There is no "appeal" task in the brand protocol. This is by design — a brand has standing to refuse association without a counter-process. + +But a consumer surface that lands on a rejected leaf SHOULD give that leaf operator a clear next-step UI. Otherwise the publisher sees their site demoted with no explanation. + +### Surface the rejection to the leaf + +If the consumer surface knows it's looking at the leaf (e.g., the leaf logged into a portfolio explorer or DSP self-service portal): + +```text +┌─────────────────────────────────────────────────────────────┐ +│ Your brand.json claims house_domain: nikeinc.com │ +│ │ +│ Nike, Inc.'s brand-agent has rejected this claim: │ +│ "We have no record of this brand; the leaf's claim is in │ +│ error." │ +│ │ +│ What this means: AdCP consumers will treat your site as │ +│ standalone (not a Nike subsidiary). Your own brand identity │ +│ is unaffected. │ +│ │ +│ Next steps: │ +│ • If you should be in Nike's portfolio, contact │ +│ (from Nike's brand.json). │ +│ • If the claim was mistaken, edit your brand.json to │ +│ remove `house_domain`, or point it at the correct │ +│ parent. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### What the leaf cannot do + +- **There is no protocol-defined challenge mechanism.** The leaf cannot "force" reconsideration through AdCP; that is an out-of-band business conversation. +- **A leaf claiming `house_domain: A` against A's published rejection does NOT establish the relationship.** Consumers will continue to treat the leaf as standalone. +- **Re-asserting via different surfaces (a new brand-agent on a new subdomain) doesn't help** — the consumer trust gate is domain control + house-side reciprocation, not number of assertions. + +## Audit trail and appeal-process notes + +Out-of-spec but worth documenting: keep a record of rejections so a brand or publisher can see the history. + +### What to record per rejected response + +- **Timestamp** of the response. +- **Caller identity** that initiated the verify call (your own user, or the upstream service). +- **Full signed envelope** of the brand-agent's response — signature included, for downstream attestation. +- **`status`**, **`context_note`**, and the `claim` payload that triggered it. +- **Cache validity window** (`Cache-Control: max-age`) so you know when the record could be stale. + +A signed envelope is durable evidence. If a buyer is challenged for excluding a property based on a Nike rejection, the signed envelope from Nike's brand-agent at that moment in time is the artifact the buyer hands back. + +### Appeal-process surface + +If your platform supports an appeal flow (a vendor-mediated dispute between leaf and house), keep it OUT of the protocol layer and IN your platform's relationship-management surface. The protocol's job is to convey the brand's authoritative answer; your platform's job is to broker the business conversation if there is one. + +## Legal exposure + +Broadcasting one brand's rejection of another party carries defamation risk. Two considerations: + +### Attribute, don't editorialize + +Render rejections as the brand's first-person statement, attributed to the brand: + +- **Good**: "Nike, Inc. has stated that fake-nike-store.com is not one of its properties." +- **Bad**: "fake-nike-store.com is a fraudulent Nike imitator." + +The first is a reportable fact (Nike's signed statement); the second is an accusation made by your platform. + +### The consumer surface is responsible, not AdCP + +AdCP delivers a signed answer from one party to another. The consumer surface — the UI that renders that answer to a third party — owns the editorial decisions about how to present it. AdCP does not pre-litigate defamation; render with care. + +Specifically: + +- **The rejecting brand owns the `context_note` text.** If a brand writes "fake-nike-store.com is a scam," that's the brand's statement and the brand's exposure. Your surface can render it verbatim or summarize it more neutrally; both are reasonable depending on your audience. +- **Your platform owns any text outside the `context_note`.** Headlines, severity labels, badges ("VERIFIED FRAUD") are your editorial choices and your exposure. +- **Status-icon design carries weight.** A red X next to a publisher's name reads differently than a yellow "house affiliation contested" badge. Choose the visual register that matches the underlying signal. + +### When in doubt, attribute and link + +The lowest-risk pattern is to attribute the statement to the brand and link to the signed source: + +```text +"Nike, Inc. says this is not theirs." [View signed response] +``` + +The buyer or reviewer can click through to the signed envelope and form their own view. Your platform delivered the signal without amplifying it. + +## Related + +- [verify_brand_claim](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim) — The task this guidance applies to +- [Building a brand agent](/dist/docs/3.0.13/brand-protocol/building-a-brand-agent) — Agent-side implementation, including the public/authorized split and signing setup +- [brand.json § Agent-augmented verification](/dist/docs/3.0.13/brand-protocol/brand-json#agent-augmented-verification) — The asymmetric trust model that makes rejections authoritative diff --git a/dist/docs/3.0.13/brand-protocol/walkthrough-rights-licensing.mdx b/dist/docs/3.0.13/brand-protocol/walkthrough-rights-licensing.mdx new file mode 100644 index 0000000000..a1494fcfb1 --- /dev/null +++ b/dist/docs/3.0.13/brand-protocol/walkthrough-rights-licensing.mdx @@ -0,0 +1,575 @@ +--- +title: Rights licensing walkthrough +sidebarTitle: Rights licensing +"og:image": /images/walkthrough/brand-panel-01-campaign-brief.png +description: "AdCP rights licensing walkthrough: follow a buyer from campaign brief through talent discovery (get_rights), acquisition (acquire_rights), creative approval, and lifecycle management including extension and revocation." +"og:title": "AdCP — Rights licensing walkthrough" +--- + +Meet Carlos. He runs programmatic at Pinnacle Media, a mid-size agency in Amsterdam. His client, Bistro Oranje — a steakhouse chain expanding across the Netherlands — wants a celebrity athlete in their next campaign. Not stock footage. Not a lookalike. A real, licensed Dutch athlete whose likeness and voice AI tools can generate into on-brand ads. + +The problem: how do you find available talent, negotiate rights, get authorization for your AI tools, and track usage — all through agents, at programmatic speed? + +This walkthrough follows Carlos from campaign brief to live delivery — and what happens when things change mid-flight. + +**The workflow in seven steps:** +1. **Brief** — Define what the campaign needs +2. **Discover** — Fetch `brand.json` to find the rights agent +3. **Search** — Query available talent with `get_rights` +4. **Acquire** — Submit a binding request with `acquire_rights` +5. **Approve** — Handle approval, rejection, or pending paths +6. **Generate** — Create and deliver on-brand ads +7. **Manage** — Extend, pause, or pull the campaign + +Carlos at his desk reviewing the Bistro Oranje campaign brief, with restaurant brand imagery on one screen and athlete photos on another + +## Step 1: The brief + +Bistro Oranje wants a Dutch Olympic athlete as the face of their summer campaign. Video ads, display banners, and audio spots across the Netherlands. Budget: EUR 5,000 for rights, plus creative production and media spend. The campaign runs June through August. + +Carlos has used AdCP for media buying before. Rights licensing works the same way — his buyer agent talks to a rights agent the same way — same protocol, same tools. + + + +| What Carlos says | What the protocol calls it | +|---|---| +| "Find me a Dutch athlete for a food brand" | `get_rights` with natural language `query` | +| "How much for likeness and voice?" | `pricing_options` in the `get_rights` response | +| "Lock in 3 months, Netherlands only" | `acquire_rights` with campaign dates and countries | +| "Send me the keys so my creative tools can generate" | `generation_credentials` in the `acquire_rights` response | +| "The talent's agency needs to approve the creative" | `creative-approval-request` via the `approval_webhook` | +| "We need to extend through September" | `update_rights` with a new `end_date` | +| "The talent got injured — pull everything" | Revocation notification to the `revocation_webhook` | + + + +--- + +## Step 2: Discover the brand + +Carlos's buyer agent starts where every AdCP interaction starts: `brand.json`. The agent fetches `https://lotientertainment.com/.well-known/brand.json` and finds a talent agency managing a roster of athletes. + +A buyer agent robot following a glowing trail from a restaurant website to a brand.json file floating in the air, revealing identity data like colors, logos, and tone + +The `rights_agent` field tells the buyer agent everything it needs to know before making any MCP calls — what is licensable, what types of rights, and where. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "lotientertainment.com", + "name": "Loti Entertainment", + "architecture": "house_of_brands" + }, + "brands": [ + { + "id": "daan_janssen", + "names": [{ "en": "Daan Janssen" }], + "description": "Dutch Olympic speed skater, 2x gold medalist", + "industries": ["sports_fitness"], + "rights_agent": { + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment", + "available_uses": ["likeness", "voice", "endorsement"], + "right_types": ["talent"], + "countries": ["NL", "BE", "DE"] + } + } + ] +} +``` + +```mermaid +sequenceDiagram + participant Buyer as Buyer Agent + participant Domain as lotientertainment.com + participant Rights as Rights Agent (MCP) + + Buyer->>Domain: GET /.well-known/brand.json + Domain-->>Buyer: brand.json with rights_agent + Note over Buyer: Discovers rights agent URL,
available uses, countries + Buyer->>Rights: get_brand_identity (brand_id: "daan_janssen") + Rights-->>Buyer: Visual identity, photos, tone +``` + +The buyer agent also calls `get_brand_identity` to retrieve visual assets and tone guidelines. These are needed later when creative tools generate on-brand ads featuring the athlete. + +```json +{ + "brand_id": "daan_janssen", + "fields": ["logos", "colors", "tone", "visual_guidelines"] +} +``` + +This returns high-res photos, brand colors, and appearance guidelines — everything a creative agent needs to generate ads that look right. + +--- + +## Step 3: Search for talent + +Now Carlos's agent calls `get_rights` on the rights agent. Carlos describes what he wants in plain language. No dropdown menus, no category codes — the agent understands intent. + +A catalog display showing talent cards with availability indicators, pricing tags, and geographic coverage maps, like a talent agency's digital showcase + +**Request:** + +```json +{ + "query": "Dutch athlete available for food and restaurant brands in the Netherlands, budget around EUR 5000 for 3 months", + "uses": ["likeness", "voice"], + "buyer_brand": { + "domain": "bistrooranje.nl" + }, + "countries": ["NL"], + "right_type": "talent", + "include_excluded": true +} +``` + +The `include_excluded: true` flag asks the rights agent to return talent that cannot be licensed as-is, along with reasons and suggestions. Without this flag, the response only includes available talent. + +The response returns Daan Janssen as a 92% match — Dutch nationality, no food category conflicts, budget-aligned. Two pricing options: CPM at EUR 3.50 per impression, or a flat monthly rate at EUR 1,500 with a 100K impression cap. The response also shows Emma van Dijk as excluded due to a food category exclusivity in the Netherlands, with suggestions for alternative markets. + + + +```json +{ + "rights": [ + { + "rights_id": "loti_dj_talent_2026", + "brand_id": "daan_janssen", + "name": "Daan Janssen", + "description": "Dutch Olympic speed skater, 2x gold medalist. Available for food, lifestyle, and fitness brands.", + "right_type": "talent", + "match_score": 0.92, + "match_reasons": [ + "Dutch nationality matches geographic request", + "No food category exclusivity conflicts", + "Budget aligns with available pricing options" + ], + "available_uses": ["likeness", "voice", "endorsement"], + "countries": ["NL", "BE", "DE"], + "exclusivity_status": { + "available": true, + "existing_exclusives": [ + "Exclusive commitment in sportswear category (NL, BE, DE)" + ] + }, + "pricing_options": [ + { + "pricing_option_id": "dj_cpm_likeness_voice", + "model": "cpm", + "price": 3.50, + "currency": "EUR", + "uses": ["likeness", "voice"], + "description": "Likeness and voice, per-impression pricing" + }, + { + "pricing_option_id": "dj_flat_monthly", + "model": "flat_rate", + "price": 1500, + "currency": "EUR", + "uses": ["likeness", "voice"], + "period": "monthly", + "impression_cap": 100000, + "overage_cpm": 4.00, + "description": "Monthly flat rate with 100K impression cap" + } + ], + "content_restrictions": [ + "No depiction in competitive sports contexts", + "Alcohol adjacency prohibited", + "Creative approval required for video formats" + ] + } + ], + "excluded": [ + { + "brand_id": "emma_van_dijk", + "name": "Emma van Dijk", + "reason": "Exclusive commitment in food and beverage category (NL)", + "suggestions": [ + "Available for food brands in BE and DE markets", + "Exclusivity expires 2026-12-31 — available in NL from January 2027" + ] + } + ] +} +``` + + + +Carlos's agent sees two pricing options for Daan Janssen. The per-impression model works if volume is unpredictable. The flat monthly rate is better for a planned campaign — EUR 1,500/month with a 100,000 impression cap means the full 3-month campaign costs EUR 4,500, within budget. + +The `excluded` array shows Emma van Dijk is unavailable in the Netherlands for food brands, but the suggestions tell Carlos's agent she is available in Belgium and Germany, or in the Netherlands starting January 2027. Suggestions mean the exclusion is actionable — the agent can adjust and retry. + +```mermaid +sequenceDiagram + participant Buyer as Buyer Agent + participant Rights as Rights Agent + + Buyer->>Rights: get_rights (query, uses, buyer_brand, countries) + Rights-->>Buyer: Matching rights with pricing options + Note over Buyer: Evaluates pricing options,
checks content restrictions,
reviews exclusion reasons +``` + +--- + +## Step 4: Acquire the rights + +Carlos reviews the options and picks the flat monthly rate. His buyer agent submits `acquire_rights` — a binding contractual request. + +Two robots, a buyer agent and a brand agent, shaking hands over a glowing contract document with terms floating above it — dates, geography, and pricing + +Carlos's agent submits `acquire_rights` with the flat monthly pricing option, campaign dates (June through August), webhooks for revocation and push notifications, and an idempotency key for safe retries. + + + +```json +{ + "rights_id": "loti_dj_talent_2026", + "pricing_option_id": "dj_flat_monthly", + "buyer": { + "domain": "bistrooranje.nl" + }, + "campaign": { + "description": "Summer steakhouse campaign featuring Daan Janssen in video, display, and audio ads promoting Bistro Oranje locations across the Netherlands", + "uses": ["likeness", "voice"], + "countries": ["NL"], + "format_ids": [ + { "agent_url": "https://creatives.pinnaclemedia.com", "id": "video_16x9_30s" }, + { "agent_url": "https://creatives.pinnaclemedia.com", "id": "display_300x250" } + ], + "estimated_impressions": 250000, + "start_date": "2026-06-01", + "end_date": "2026-08-31" + }, + "revocation_webhook": { + "url": "https://api.pinnaclemedia.com/webhooks/revocation", + "auth": { + "type": "bearer", + "token": "whk_pinnacle_abc123" + } + }, + "push_notification_config": { + "url": "https://api.pinnaclemedia.com/webhooks/rights-updates", + "auth": { + "type": "bearer", + "token": "whk_pinnacle_def456" + } + }, + "idempotency_key": "bistro-dj-summer-2026-v1" +} +``` + + + +Three outcomes are possible. + +```mermaid +flowchart TD + A[acquire_rights request] --> B{Rights agent
evaluates} + B -->|Cleared| C["acquired + Terms + credentials + rights constraint"] + B -->|Needs review| D["pending_approval + Estimated response time"] + B -->|Cannot fulfill| E["rejected + Reason + optional suggestions"] + + C --> F[Creative generation begins] + D --> G[Webhook notification when resolved] + E --> H{Suggestions
present?} + H -->|Yes| I[Adjust and resubmit] + H -->|No| J[Move on to other talent] + + style C fill:#047857,color:#fff + style D fill:#d97706,color:#fff + style E fill:#dc2626,color:#fff +``` + +Carlos's agent gets everything it needs to start creating: keys for Midjourney (likeness) and ElevenLabs (voice), the legal disclosure text for every ad, and a link to submit finished creatives for the talent's approval. The monthly cap of 100,000 impressions translates to a total campaign cap of 300,000 across the three-month term. + + + +```json +{ + "rights_id": "loti_dj_talent_2026", + "status": "acquired", + "brand_id": "daan_janssen", + "terms": { + "pricing_option_id": "dj_flat_monthly", + "amount": 1500, + "currency": "EUR", + "period": "monthly", + "uses": ["likeness", "voice"], + "impression_cap": 100000, + "overage_cpm": 4.00, + "start_date": "2026-06-01", + "end_date": "2026-08-31" + }, + "generation_credentials": [ + { + "provider": "midjourney", + "rights_key": "rk_mj_dj_2026_bistro_7f3a9b", + "uses": ["likeness"], + "expires_at": "2026-09-01T00:00:00Z" + }, + { + "provider": "elevenlabs", + "rights_key": "rk_el_dj_2026_bistro_4e8c1d", + "uses": ["voice"], + "expires_at": "2026-09-01T00:00:00Z" + } + ], + "restrictions": [ + "No depiction in competitive sports contexts", + "Alcohol adjacency prohibited", + "Creative approval required for video formats" + ], + "disclosure": { + "required": true, + "text": "Features AI-generated likeness of Daan Janssen, used under license from Loti Entertainment." + }, + "approval_webhook": { + "url": "https://rights.lotientertainment.com/api/creative-approval", + "auth": { + "type": "bearer", + "token": "appr_loti_dj_2026_9x4k" + } + }, + "usage_reporting_url": "https://rights.lotientertainment.com/api/usage", + "rights_constraint": { + "rights_id": "loti_dj_talent_2026", + "rights_agent": { + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment" + }, + "valid_from": "2026-06-01T00:00:00Z", + "valid_until": "2026-09-01T00:00:00Z", + "uses": ["likeness", "voice"], + "countries": ["NL"], + "impression_cap": 300000, + "right_type": "talent", + "verification_url": "https://rights.lotientertainment.com/verify/loti_dj_talent_2026" + } +} +``` + + + +--- + +## Step 5: Approval and rejection + +Not every request gets approved immediately. The protocol handles two other paths. + +Split panel showing two paths: left side has a golden key credential being handed over for an approved request; right side shows a rejected stamp with two branches — one looping back with a lightbulb for suggestions, another with a final X + +### Pending approval + +Some requests need human review — the talent's management, a legal team, or the athlete themselves. The rights agent returns `pending_approval` with an estimated timeline. + +```json +{ + "rights_id": "loti_dj_talent_2026", + "status": "pending_approval", + "brand_id": "daan_janssen", + "detail": "Video format requests require creative concept review by talent management", + "estimated_response_time": "48h" +} +``` + +When the decision is made, the rights agent sends a webhook to the `push_notification_config` URL. The buyer agent does not poll. + +### Rejection with suggestions (actionable) + +If the request cannot be fulfilled as-is, but the buyer can adjust, the rejection includes `suggestions`. Their presence is the signal — if suggestions exist, the rejection is fixable. + +```json +{ + "rights_id": "loti_dj_talent_2026", + "status": "rejected", + "brand_id": "daan_janssen", + "reason": "Requested dates conflict with an existing exclusivity commitment in the food category for this market", + "suggestions": [ + "Available in NL from 2026-09-01 onward", + "Available immediately in BE and DE markets", + "Consider likeness-only (without voice) — available at reduced rate" + ] +} +``` + +Carlos's agent can revise — shift the dates, change the geography, or drop voice rights — and resubmit. + +### Rejection without suggestions (final) + +When there are no suggestions, the rejection is final for this talent and campaign combination. + +```json +{ + "rights_id": "loti_dj_talent_2026", + "status": "rejected", + "brand_id": "daan_janssen", + "reason": "This request does not meet the talent's current licensing criteria" +} +``` + +No `suggestions` field. The buyer agent moves on. The reason may be vague intentionally — agencies manage confidential rules (the talent's personal boundaries, legal constraints, internal policies) that are not appropriate to disclose. The protocol respects this: a sanitized reason is enough for the buyer agent to understand the outcome without exposing private business logic. + +--- + +At this point, Carlos has licensed talent and is ready to generate creative. The next section covers what happens after acquisition: generation, delivery, and lifecycle management. + + + + Learn the protocol hands-on through the buyer certification program. + + + How rights licensing works from the buyer's perspective. + + + +--- + +## Step 6: Generate and deliver + +With credentials in hand, Carlos's creative tools go to work. The Midjourney credential generates likeness. The ElevenLabs credential generates voice. Each provider validates the `rights_key` at generation time — the credential itself is the authorization. + +Carlos reviewing AI-generated ads on his screen — a display banner and video still featuring the licensed athlete, with a verification badge and shield icon + +### Creative approval + +For video formats, the content restrictions require approval before distribution. The creative agent submits the finished ad to the `approval_webhook`. + +```json +{ + "rights_id": "loti_dj_talent_2026", + "creative_id": "bistro_summer_video_01", + "creative_url": "https://cdn.pinnaclemedia.com/creatives/bistro_summer_video_01.mp4", + "creative_format": { + "agent_url": "https://creatives.pinnaclemedia.com", + "id": "video_16x9_30s" + }, + "description": "30-second video spot featuring Daan Janssen recommending Bistro Oranje summer menu" +} +``` + +### Rights constraint in the creative manifest + +Every creative that uses licensed talent carries the `rights_constraint` from the `acquire_rights` response in its manifest — the buyer does not construct it manually. A single ad can combine talent likeness from one rights holder and music from another, each with different validity periods and geographic restrictions. Downstream participants (SSPs, verification vendors) hit the `verification_url` to confirm the grant is still active before serving. + +### Usage reporting + +Impressions are reported back to the rights agent for billing and cap enforcement. + +The `impression_cap` in the terms (100,000 per month) is a soft cap by default. If the campaign exceeds it, additional impressions are billed at the `overage_cpm` rate (EUR 4.00). The rights agent tracks cumulative usage and can notify the buyer when approaching the cap. + +--- + +## Step 7: The lifecycle continues + +Rights are not static. Campaigns change, contracts extend, and sometimes things go wrong. + +Timeline view showing the full rights lifecycle: an active campaign with an impression counter ticking, a pause and update midstream, and a revocation alert notification appearing on Carlos's screen + +### Updating rights + +Midway through the campaign, Bistro Oranje wants to extend through September. Carlos's agent calls `update_rights`. + +```json +{ + "rights_id": "loti_dj_talent_2026", + "end_date": "2026-09-30", + "impression_cap": 150000, + "idempotency_key": "bistro-dj-extend-sept-v1" +} +``` + +The response includes updated terms and re-issued generation credentials with the new expiration. The old credentials continue working during an overlap period — no gap in creative delivery. + +If the campaign needs to pause (talent injury, brand issue, seasonal break), the agent can set `paused: true`. Credentials are suspended. Set `paused: false` to resume. + +### Natural expiration + +When the campaign ends and `valid_until` is reached, credentials expire automatically. Generation requests stop working. No action is required from either party. + +### Revocation + +If the talent's agency needs to revoke rights — a scandal, a contract violation, a legal issue — they POST a revocation notification to the buyer's `revocation_webhook`. + +```json +{ + "idempotency_key": "rev_01HW9DHSP8TV1N3R5T7V9X1Z3B5D", + "rights_id": "loti_dj_talent_2026", + "brand_id": "daan_janssen", + "reason": "Rights revoked due to updated talent representation terms", + "effective_at": "2026-07-15T18:00:00Z" +} +``` + +The buyer acknowledges receipt. If the notification fails to deliver, the rights agent retries automatically. + +When `effective_at` is in the future, the buyer has a grace period to wind down creative delivery. When it is the current time, the revocation is immediate — stop serving now. + +Partial revocation is also supported. If `revoked_uses` is present in the notification, only those uses are revoked. The rest of the grant remains active. + +```mermaid +stateDiagram-v2 + [*] --> Active: acquire_rights (acquired) + Active --> Active: update_rights (extend, adjust cap) + Active --> Paused: update_rights (paused: true) + Paused --> Active: update_rights (paused: false) + Active --> PartialRevocation: Revocation (revoked_uses present) + PartialRevocation --> Active: Remaining uses continue + Active --> Revoked: Revocation (full) + Paused --> Revoked: Revocation (full) + Active --> Expired: valid_until reached + Revoked --> [*] + Expired --> [*] +``` + +--- + +## What you have seen + +The brand protocol handles rights from discovery (`brand.json`) through licensing (`get_rights`, `acquire_rights`) to lifecycle management (`update_rights`, revocation webhooks). Every step uses the same MCP transport your buyer agent already speaks. The rights constraint travels with the creative — so every participant in the supply chain can verify before serving. + + + +Bistro Oranje's campaign is halfway through July when they learn Daan Janssen has signed a new sportswear exclusivity deal that now includes food brands in Belgium. Their campaign only runs in the Netherlands. Does Carlos need to do anything? Why or why not? + +*Think about: geographic scoping in the rights grant, the difference between existing exclusivities and new ones, and what the `countries` field in Carlos's `acquire_rights` terms actually covers.* + + + +--- + +## Go deeper + + + + Full technical specification for the brand.json file format. + + + How rights licensing works from the buyer's perspective. + + + How AdCP protects and monetizes talent rights. + + + Implement a brand agent that serves identity and rights. + + + Search for licensable talent with pricing and availability. + + + Submit a binding request and receive generation credentials. + + + Modify an existing rights grant — extend, pause, adjust. + + + Earn your buyer certification and learn the protocol hands-on. + + diff --git a/dist/docs/3.0.13/building/build-an-agent.mdx b/dist/docs/3.0.13/building/build-an-agent.mdx new file mode 100644 index 0000000000..57f239dc29 --- /dev/null +++ b/dist/docs/3.0.13/building/build-an-agent.mdx @@ -0,0 +1,209 @@ +--- +title: Build an Agent +sidebarTitle: Build an Agent +description: "Use AdCP SDK skill files to generate storyboard-compliant agents with a coding agent in minutes." +"og:title": "AdCP — Build an Agent" +--- + +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.0.13/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 +npm install @adcp/client +``` + +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/client) +- [GitHub Repository](https://github.com/adcontextprotocol/adcp-client) + + +```bash +pip install adcp +``` + +The Python SDK provides the same capabilities — subclass `ADCPHandler`, implement tools, and use response builders for every return value: + +```python +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 +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`, `sales-proposal-mode` | +| `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` | + +Building a **brand rights** agent (licensing talent, music, stock media)? There's no skill today — see the [Brand Protocol docs](/dist/docs/3.0.13/brand-protocol) and claim `brand-rights` under the `brand` domain. + +See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for every domain and specialism with its storyboard and status (stable, preview, deprecated). + +## 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 +# JS/TS agent +npx tsx agent.ts & +npx @adcp/client@latest storyboard run http://localhost:3001/mcp media_buy_seller --json + +# Python agent +python agent.py & +npx @adcp/client@latest storyboard run http://localhost:3001/mcp media_buy_seller --json + +# Go agent +go run main.go & +npx @adcp/client@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.0.13/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/client@latest storyboard list` to see all available storyboards. + + +See **[Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent)** for the full testing workflow — debugging failing steps, running compliance checks, and validating interactively through Addie. + +## 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/client/docs/llms.txt` | Full protocol in one file — tools, types, error codes, examples | +| Server guide | `node_modules/@adcp/client/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.0.13/building/verification/validate-your-agent)** — Storyboards, compliance checks, and the build-validate-fix loop +- **[Operating an Agent](/dist/docs/3.0.13/building/operating/operating-an-agent)** — What sits behind the protocol layer, and whether to partner, self-host, or build +- **[Schemas and SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas)** — Schema access, CLI tools, and SDK package exports +- **[MCP integration guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide)** — Transport, sessions, and auth details +- **[Task lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling +- **[Error handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery diff --git a/dist/docs/3.0.13/building/by-layer/L0/a2a-guide.mdx b/dist/docs/3.0.13/building/by-layer/L0/a2a-guide.mdx new file mode 100644 index 0000000000..2c836ec8e3 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/a2a-guide.mdx @@ -0,0 +1,1025 @@ +--- +title: A2A Guide +description: "AdCP A2A integration guide: client setup, agent card verification, SSE streaming for async tasks, artifact handling, and response format for Agent-to-Agent Protocol." +"og:title": "AdCP — A2A Guide" +--- + + +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.0.13/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.0.13/building/by-layer/L0/a2a-response-extraction)) accept both shapes during the compatibility period. + +## A2A Client Setup + +### 1. Initialize A2A Client + +```javascript +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 +// 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 +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 +// 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 +// 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 +// 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 +// 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.0.13/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 +{ + "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.0.13/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.0.13/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 +// 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.0.13/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.0.13/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 +// 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 +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.0.13/building/by-layer/L3/webhooks). + +## SSE Streaming (A2A-Specific) + +A2A's key advantage is real-time updates via Server-Sent Events: + +### Task Monitoring + +```javascript +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 +// 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 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; + 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 +{ + "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 +{ + "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.0.13/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.0.13/core/async-response-data.json) + +### Webhook Handler Example + +```javascript +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 +// 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 +// 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 +// 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 +// 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.0.13/building/by-layer/L3/webhooks). + +### Skill Structure +```javascript +// 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.0.13/media-buy) and [Signals](/dist/docs/3.0.13/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 +// 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 +{ + "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 +{ + "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.0.13/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 +// 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.0.13/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.0.13/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 +// 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.0.13/building/operating/transport-errors). + +```javascript +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.0.13/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.0.13/building/by-layer/L3/task-lifecycle) for status handling and workflows +- **Task Reference**: See [Media Buy Tasks](/dist/docs/3.0.13/media-buy) and [Signals](/dist/docs/3.0.13/signals/overview) +- **Protocol Comparison**: Compare with [MCP integration](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/task-lifecycle) - this guide focuses on A2A transport specifics only.** \ No newline at end of file diff --git a/dist/docs/3.0.13/building/by-layer/L0/a2a-response-extraction.mdx b/dist/docs/3.0.13/building/by-layer/L0/a2a-response-extraction.mdx new file mode 100644 index 0000000000..6c031e9983 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/a2a-response-extraction.mdx @@ -0,0 +1,277 @@ +--- +title: A2A Response Extraction +description: "How to extract AdCP response data from A2A Task objects: status-based branching, last-DataPart authority, wrapper rejection, and client implementation requirements." +"og:title": "AdCP — A2A Response Extraction" +--- + +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.0.13/building/by-layer/L0/a2a-response-format). For error-specific extraction, see [Transport Error Mapping](/dist/docs/3.0.13/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 +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 +{ + "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 +// 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 +// 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.0.13/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 +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.0.13/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.0.13/building/by-layer/L0/a2a-response-format) — canonical response structure for sellers +- [Transport Error Mapping](/dist/docs/3.0.13/building/operating/transport-errors) — error extraction from MCP and A2A +- [MCP Response Extraction](/dist/docs/3.0.13/building/by-layer/L0/mcp-response-extraction) — equivalent spec for MCP +- [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) — A2A transport integration diff --git a/dist/docs/3.0.13/building/by-layer/L0/a2a-response-format.mdx b/dist/docs/3.0.13/building/by-layer/L0/a2a-response-format.mdx new file mode 100644 index 0000000000..6a08663b48 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/a2a-response-format.mdx @@ -0,0 +1,822 @@ +--- +title: A2A Response Format +description: "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." +"og:title": "AdCP — A2A Response Format" +--- + + +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.0.13/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 +{ + "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 +{ + "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.0.13/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 +// ❌ 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.0.13/building/by-layer/L0/a2a-response-extraction#extraction-algorithm). + +```javascript +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 +// 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 +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 +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 +// 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 +// 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 +// 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 +{ + "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 +{ + "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 +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.0.13/building/by-layer/L3/webhooks). + +## File Parts in Responses + +Creative operations MAY include file references: + +```json +{ + "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 +// 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/building/by-layer/L0/a2a-guide) - Complete A2A integration guide +- [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) - Status handling patterns +- [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) - Fatal vs non-fatal errors +- [Protocol Comparison](/dist/docs/3.0.13/building/concepts/protocol-comparison) - MCP vs A2A differences diff --git a/dist/docs/3.0.13/building/by-layer/L0/index.mdx b/dist/docs/3.0.13/building/by-layer/L0/index.mdx new file mode 100644 index 0000000000..f85c9e9e77 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/index.mdx @@ -0,0 +1,28 @@ +--- +title: L0 — Wire & transport +sidebarTitle: L0 — Wire & transport +description: "Wire-and-transport layer of the AdCP stack. JSON-over-HTTP framing, MCP message envelopes, A2A SSE streams, schema validation, language-native type generation." +"og:title": "AdCP — L0 (Wire & transport)" +--- + +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.0.13/building/cross-cutting/sdk-stack#l0--wire--transport). + +## Pages in this layer + +- **[Schemas](/dist/docs/3.0.13/building/by-layer/L0/schemas)** — schema bundle, supply-chain verification, type generation, version pinning. +- **[MCP guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide)** — `tools/call` envelope, JSON-RPC 2.0, transport adapter shape. +- **[A2A guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide)** — SSE event streams, task framing, artifact extraction. +- **[A2A response format](/dist/docs/3.0.13/building/by-layer/L0/a2a-response-format)** — A2A wire-format reference. +- **[MCP response extraction](/dist/docs/3.0.13/building/by-layer/L0/mcp-response-extraction)** — parsing `tools/call` responses to typed values. +- **[A2A response extraction](/dist/docs/3.0.13/building/by-layer/L0/a2a-response-extraction)** — parsing A2A streams and artifacts. diff --git a/dist/docs/3.0.13/building/by-layer/L0/mcp-guide.mdx b/dist/docs/3.0.13/building/by-layer/L0/mcp-guide.mdx new file mode 100644 index 0000000000..5c7e104c98 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/mcp-guide.mdx @@ -0,0 +1,880 @@ +--- +title: MCP Guide +description: "AdCP MCP integration guide: tool call patterns, context_id management, response parsing, and wire format for Model Context Protocol implementations." +"og:title": "AdCP — MCP Guide" +--- + + +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.0.13/building/by-layer/L3/task-lifecycle). + +## Testing AdCP via MCP + +You can test AdCP tasks using the [CLI tools](/dist/docs/3.0.13/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 +// 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 +// 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 +// 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.0.13/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 +{ + "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.0.13/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.0.13/building/by-layer/L3/task-lifecycle) for complete status handling patterns. + +**Status Handling**: See [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) for complete status handling patterns. + +## Available Tools + +All AdCP tasks are available as MCP tools: + +### Protocol Tools +```javascript +await mcp.call('get_adcp_capabilities', {...}); // Discover agent capabilities (start here) +``` + +### Media Buy Tools +```javascript +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 +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.0.13/media-buy) and [Signals](/dist/docs/3.0.13/signals/overview) sections. + +## Async Operations via MCP Tasks + +AdCP uses [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) for long-running operations over MCP. This removes the LLM from the polling path — the client handles task lifecycle at the protocol level, and the model only sees the final result. + +:::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 **webhooks** or **polling via `tasks/get`** instead — these work with any MCP client. See [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for transport-independent patterns. + +MCP Tasks are the right choice when you control the MCP client (e.g., building your own orchestrator with `@modelcontextprotocol/sdk`) or when client support matures. +::: + +### 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 +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 +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 +{ + "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 via `tasks/get`, retrieve the result via `tasks/result` + +### Invoking a Tool as a Task + +Include the `task` field in your `tools/call` request: + +```json +{ + "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 +{ + "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 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 a running task, send `tasks/cancel` with the `taskId`. + +### AdCP Status Mapping + +AdCP uses a richer set of statuses than MCP Tasks. When serving over MCP, AdCP statuses map to MCP Task statuses: + +| 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 polling within the MCP session, but some AdCP operations outlive a single session (e.g., a media buy that takes 24 hours for publisher approval). For these, combine MCP Tasks with `push_notification_config`: + +```json +{ + "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 status within the session. If the session ends before the task completes, the webhook delivers the result independently. See [Push Notifications](/dist/docs/3.0.13/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 +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 +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 +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 — the client handles polling via tasks/get +``` + +**Webhook POST format:** +```json +{ + "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.0.13/core/mcp-webhook-payload.json) and [Webhooks — Operation IDs](/dist/docs/3.0.13/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.0.13/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.0.13/core/async-response-data.json) + +#### Webhook Handler Example + +```javascript +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 +// Check status of specific task +const taskStatus = await session.pollTask('task_456', true); +if (taskStatus.status === 'completed') { + console.log('Result:', taskStatus.result); +} + +// State reconciliation +const reconciliation = await session.reconcileState(); +if (reconciliation.missing_from_client.length > 0) { + console.log('Found orphaned tasks:', reconciliation.missing_from_client); + // 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 +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 a task 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, but requires client support | + +### 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 +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.0.13/building/by-layer/L3/webhooks) for payload formats and authentication. + +### Option 2: Polling (backup) + +Use `tasks/get` as a backup for `submitted` operations, or when you can't expose a webhook endpoint: + +```javascript +async function pollForResult(session, taskId, pollInterval = 30000) { + while (true) { + const response = await session.pollTask(taskId, 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 +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 +// 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.0.13/building/by-layer/L1/security#server-side-tool-wrapper-conformance) for the normative rule. + +### Tool Discovery +```javascript +// 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.0.13/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 +{ + "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 +// 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.0.13/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.0.13/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.0.13/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 +// 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.0.13/building/operating/transport-errors). + +```javascript +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.0.13/building/by-layer/L3/task-lifecycle) for status handling and workflows +- **Task Reference**: See [Media Buy Tasks](/dist/docs/3.0.13/media-buy) and [Signals](/dist/docs/3.0.13/signals/overview) +- **Protocol Comparison**: Compare with [A2A integration](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/task-lifecycle) - this guide focuses on MCP transport specifics only.** \ No newline at end of file diff --git a/dist/docs/3.0.13/building/by-layer/L0/mcp-response-extraction.mdx b/dist/docs/3.0.13/building/by-layer/L0/mcp-response-extraction.mdx new file mode 100644 index 0000000000..b09b4d7e71 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/mcp-response-extraction.mdx @@ -0,0 +1,163 @@ +--- +title: MCP Response Extraction +description: "How to extract AdCP success response data from MCP tool results: structuredContent, text fallback, and client implementation requirements." +"og:title": "AdCP — MCP Response Extraction" +--- + +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.0.13/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.0.13/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 +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 +{ + "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 +{ + "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 +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.0.13/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.0.13/building/operating/transport-errors) — error extraction from MCP and A2A +- [A2A Response Extraction](/dist/docs/3.0.13/building/by-layer/L0/a2a-response-extraction) — equivalent spec for A2A +- [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide) — MCP transport integration diff --git a/dist/docs/3.0.13/building/by-layer/L0/schemas.mdx b/dist/docs/3.0.13/building/by-layer/L0/schemas.mdx new file mode 100644 index 0000000000..915b7fde5f --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L0/schemas.mdx @@ -0,0 +1,236 @@ +--- +title: Schemas +sidebarTitle: Schemas +description: "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." +"og:title": "AdCP — Schemas" +--- + +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.0.13/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.0.13/` | 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 +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 +# 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.0.13/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.0.13/building/verification/compliance-catalog) for every protocol and specialism an agent can claim. + +## Common schemas + +| Schema | URL | +|--------|-----| +| Product | `https://adcontextprotocol.org/schemas/3.0.13/core/product.json` | +| Media Buy | `https://adcontextprotocol.org/schemas/3.0.13/core/media-buy.json` | +| Creative Format | `https://adcontextprotocol.org/schemas/3.0.13/core/format.json` | +| Schema Registry | `https://adcontextprotocol.org/schemas/3.0.13/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.0.13/`, `/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 +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 +const schema = await fetch( + 'https://adcontextprotocol.org/schemas/3.0.13/core/product.json' +); +``` + +### SDK type generation + +```bash +# 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 +# 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 task schemas +├── core/ # Modular schemas with $ref +├── media-buy/ +└── index.json # Schema registry +``` + +### 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 | +| `bundled/core/` | tasks-get, tasks-list | + +See the [schema registry](https://adcontextprotocol.org/schemas/3.0.13/index.json) for all available schemas. + +## Version discovery + +```bash +# Get the full semver of the published schema 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.0.13/index.json | jq '.published_version' +``` + +Check [Release Notes](/dist/docs/3.0.13/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. + diff --git a/dist/docs/3.0.13/building/by-layer/L1/index.mdx b/dist/docs/3.0.13/building/by-layer/L1/index.mdx new file mode 100644 index 0000000000..6269cbaa28 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L1/index.mdx @@ -0,0 +1,24 @@ +--- +title: L1 — Identity & signing +sidebarTitle: L1 — Identity & signing +description: "Identity-and-signing layer of the AdCP stack. RFC 9421 HTTP message signatures, public-key resolution, replay-window enforcement, key rotation." +"og:title": "AdCP — L1 (Identity & signing)" +--- + +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.0.13/building/cross-cutting/sdk-stack#l1--identity--signing). + +## Pages in this layer + +- **[Security implementation profile](/dist/docs/3.0.13/building/by-layer/L1/security)** — RFC 9421 wire details, KMS integration, replay-window tuning. +- **[Webhook verifier tuning](/dist/docs/3.0.13/building/by-layer/L1/webhook-verifier-tuning)** — clock-skew handling, key-rotation transitions, signature failure diagnostics. diff --git a/dist/docs/3.0.13/building/by-layer/L1/request-signing.mdx b/dist/docs/3.0.13/building/by-layer/L1/request-signing.mdx new file mode 100644 index 0000000000..7cff8876b5 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L1/request-signing.mdx @@ -0,0 +1,749 @@ +--- +title: Request Signing Guide +description: "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." +"og:title": "AdCP — Request Signing Guide" +--- + +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.0.13/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.0.13/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 needs **separate keys per purpose**, each with a distinct `kid` and `adcp_use` tag: + +- `adcp_use: "request-signing"` — for signing outbound tool calls +- `adcp_use: "webhook-signing"` — for signing outbound webhooks + +Reusing a key across purposes is forbidden by the spec. + +### 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 +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 +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 +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 +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 webhook keys — never reuse a request-signing key for webhook signing (per `adcp_use` purpose separation). + + + +### 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.0.13/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 +{ + "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 serve both request-signing and webhook-signing keys, include both in the same JWKS with different `kid` values and `adcp_use` tags. + +### brand.json + +Serve at `/.well-known/brand.json` on your brand domain. The `jwks_uri` is how verifiers find your keys: + +```json +{ + "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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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="webhook-signing", 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 +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 +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 +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 a separate JWK with `"adcp_use": "webhook-signing"` in your JWKS alongside your request-signing key. Never reuse the same key for both purposes — receivers enforce purpose at the JWK `adcp_use` level, not the RFC 9421 tag. + +## 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 +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 +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 +// 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.0.13/building/by-layer/L1/security#revocation) for the revocation list format. + +## Testing + +### Conformance vectors + +The spec ships **39 test vectors** at `compliance/cache/3.0.0/test-vectors/request-signing/` (source at `static/compliance/source/test-vectors/request-signing/`): + +- **12 positive vectors**: valid signed requests your verifier must accept (non-4xx) +- **27 negative vectors**: invalid requests your verifier must reject with `401` and the correct error code + +```bash +# 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 +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.0.13/building/by-layer/L1/security#transport-error-taxonomy). + +## Related + +- [Security: Signed Requests](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/webhooks) — webhook setup including signature verification +- [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) — full compliance validation including signing conformance +- [Build an Agent](/dist/docs/3.0.13/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 diff --git a/dist/docs/3.0.13/building/by-layer/L1/security.mdx b/dist/docs/3.0.13/building/by-layer/L1/security.mdx new file mode 100644 index 0000000000..eee5575b48 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L1/security.mdx @@ -0,0 +1,1943 @@ +--- +title: Security +description: "AdCP security guide: risk classification for financial operations, webhook HMAC verification, replay prevention, access control, and credential management for production deployments." +"og:title": "AdCP — Security" +--- + + +**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.0.13/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 its adagents.json-published key, and the buyer verifies against the seller's published JWKS. 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.0.13/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`, collection-list `webhook_url`, TMP provider `endpoint`, `adagents.json` `authoritative_location`, and `reporting_bucket.setup_instructions`. + +Account-level webhook subscribers that receive high-volume event families, including wholesale feed webhooks, 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 activation challenge or equivalent proof-of-control before treating those subscribers as active. + +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). +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.0.13/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.0.13/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.0.13/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.0.13/governance/property/managed-networks#security-considerations) — validator fetch semantics, change detection, relationship termination. +- [TMP provider registration](/dist/docs/3.0.13/trusted-match/specification#provider-registration-security) — dynamic registration authentication, router-to-provider auth, `/health` info-leakage rules. + +## Authentication Best Practices + +### Credential Storage + +```javascript +// 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 +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.0.13/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.0.13/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.0.13/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.0.13/reference/glossary#a) and [Agent](/dist/docs/3.0.13/reference/glossary#a). + +### The two-step pattern + +Every request carries an explicit `account` (via `account_id` for explicit-account models, or the `{brand, operator}` natural key for implicit models). 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 +// 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 +-- 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 +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.0.13/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.0.13/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.0.13/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.0.13/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 `error.details.retry_after` (seconds, integer) 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 `error.details.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 +{ + "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 +{ + "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.0.13/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.0.13/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 implicit-accounts 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 +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.0.13/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.0.13/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. +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 +{ + "payload": "", + "signatures": [ + { "protected": "", + "signature": "" } + ] +} +``` + +The payload (JWS-flattened JSON serialization; compact form is also acceptable): + +```json +{ + "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 +{ + "alg": "EdDSA", + "kid": "gov-2026-04", + "typ": "adcp-gov+jws" +} +``` + +Payload: +```json +{ + "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 +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 +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.0.13/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's `adagents.json` agent entries. 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 an `adcp_use: "webhook-signing"` key 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. Cross-purpose key reuse is forbidden: 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). +- 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.0.13/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) (`adcp_use: "webhook-signing"`). The split is deliberate — see [Security Model: What gets signed](/dist/docs/3.0.13/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.0.13/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.0.13/brand-protocol/tasks/verify_brand_claim#trust-model) and [Building a brand agent — Signing setup](/dist/docs/3.0.13/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. + +#### 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. An operator signing both AdCP requests and webhooks needs distinct key material and must publish two entries with the same JWK shape, distinct `x`, distinct `kid`, and distinct `adcp_use`. The value is 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 +{ + "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": "webhook-signing", + "key_ops": ["verify"] + } + ] +} +``` + +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.0.13/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 (or the `adagents.json` equivalent for seller-side agents publishing keys for webhook callbacks). Every agent that signs — of any `type` — uses the same publication pattern. + +**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.0.13/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. Publishers hold the corresponding private key locally with `["sign"]` per JWK spec. | +| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile), `"webhook-signing"` (seller→buyer webhook callbacks), and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. Sellers that also sign webhooks publish a separate `"webhook-signing"` key under their `adagents.json` entry — see [Webhook callbacks](#webhook-callbacks). | +| `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`: 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-signing and webhook-signing 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, webhook-signing, TMP keys + +Operators SHOULD go further and serve each signing purpose from a distinct subdomain (up to four origins). 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. 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 transport-signing and 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 **purpose AND role** (sender-vs-receiver position, not just signing purpose): + - **Sell-side webhook-signing 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-signing` purpose, sell-side role) — it does NOT override operator-side webhook-signing (e.g., a buyer-hosted webhook receiving operator status callbacks). + - **All other (purpose, role) tuples** — request-signing (any direction), operator-side webhook-signing, 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 `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 purpose. Mismatch on any purpose → reject with `request_signature_key_origin_mismatch` carrying `{ purpose, expected_origin, actual_origin }`. Skip the check **only** for the specific (agent, purpose, role) tuple whose source was a publisher pin — operator-side use of the same purpose 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-signing only, prefer the publisher's `adagents.json signing_keys` pin (when present) over the operator's `jwks_uri`. For all other (purpose, role) tuples, use the matched entry's `jwks_uri` (default: `/.well-known/jwks.json` at the origin of `A`). +7. **Consistency check.** For every 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, purpose, 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 +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 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 +{ + "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 +{ + "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.** Webhook-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. An agent that signs both outgoing requests and outgoing webhooks publishes one JWKS with two distinct JWKs differentiated by `adcp_use`. Each webhook-signing JWK MUST declare: + +| Member | Value | +|---|---| +| `use` | `"sig"` | +| `key_ops` | `["verify"]` | +| `adcp_use` | `"webhook-signing"` | +| `kid` | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` | +| `alg` | `"EdDSA"` or `"ES256"` | + +Cross-purpose reuse is forbidden and locally enforceable: a request-signing key MUST NOT verify a webhook signature, and a webhook-signing key MUST NOT verify a request signature. Buyers verifying a webhook MUST reject any JWK whose `adcp_use` is not exactly `"webhook-signing"` with `webhook_signature_key_purpose_invalid`. + +**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 across `adcp_use` values within the same JWKS are forbidden specifically so a request-signing-key compromise cannot be repurposed as a webhook-signing capability. + +**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` equals `"webhook-signing"`. Reject on any mismatch, including absent `adcp_use` (`webhook_signature_key_purpose_invalid`). +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.0.13/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.0.13/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` ≠ `webhook-signing` | `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"` +- webhook signing (RFC 9421) — `adcp_use: "webhook-signing"` +- 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.0.13/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 +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 +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 +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" — everywhere else, zero. +- 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.0.13/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 +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 +// 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 +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.0.13/building/concepts/security-model) for the threat model and the five-layer defense narrative this reference implements +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for webhook security patterns +- **Error Handling**: See [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) for authentication errors +- **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design) for multi-tenant security diff --git a/dist/docs/3.0.13/building/by-layer/L1/webhook-verifier-tuning.mdx b/dist/docs/3.0.13/building/by-layer/L1/webhook-verifier-tuning.mdx new file mode 100644 index 0000000000..752132bfa2 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L1/webhook-verifier-tuning.mdx @@ -0,0 +1,149 @@ +--- +title: Webhook Verifier Tuning Guide +sidebarTitle: Webhook Verifier Tuning +description: "Non-normative tuning recipes for webhook verifier thresholds — starting values, baselining methodology, and attack-scenario walkthroughs." +"og:title": "AdCP — Webhook Verifier Tuning Guide" +--- + + +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.0.13/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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/building/by-layer/L2/account-state.mdx b/dist/docs/3.0.13/building/by-layer/L2/account-state.mdx new file mode 100644 index 0000000000..edf19ae9c9 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L2/account-state.mdx @@ -0,0 +1,249 @@ +--- +title: Account state +description: "AdCP account state model: how accounts hold catalogs, creatives, audiences, event sources, and campaigns. Sync tasks, upsert semantics, and async approval workflows." +"og:title": "AdCP — Account state" +--- + +# 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 +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 +{ + "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 +{ + "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 +{ + "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.0.13/accounts/tasks/sync_governance) registers governance agents on the account. Once configured, sellers with governance support will call [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance) before confirming media buys. + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +// 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.0.13/building/by-layer/L2/accounts-and-agents)** — Account identity, billing models, and `sync_accounts` details +- **[Async operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations)** — How async approval workflows work +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** — Receiving notifications when async operations complete +- **[Catalogs](/dist/docs/3.0.13/creative/catalogs)** — Typed data feeds that provide the items publishers render in ads diff --git a/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents.mdx b/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents.mdx new file mode 100644 index 0000000000..a5091713aa --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents.mdx @@ -0,0 +1,527 @@ +--- +title: Accounts and agents +description: "AdCP accounts and agents: the four entities in every transaction (brand, account, operator, agent), explicit vs implicit account models, and billing configuration." +"og:title": "AdCP — Accounts and agents" +--- + +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.0.13/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` (explicit accounts) or a natural key (`brand`, `operator`) (implicit accounts). Sandbox accounts follow the same model — explicit sandboxes use `account_id`, implicit 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.0.13/accounts/overview) for the full commercial model and [sync_accounts](/dist/docs/3.0.13/accounts/tasks/sync_accounts) for the task reference. + +## What sellers declare + +Sellers configure the `account` section of [`get_adcp_capabilities`](/dist/docs/3.0.13/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 single field determines both the authentication model and how accounts are referenced: + +When `false` (default) — **implicit 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` — **explicit accounts**: 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. The buyer discovers accounts via `list_accounts` and passes a seller-assigned `account_id`. + +For sandbox, the path follows the account model: explicit accounts (`require_operator_auth: true`) discover pre-existing test accounts via `list_accounts`; implicit accounts declare sandbox via `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.0.13/accounts/tasks/get_account_financials). This only applies to operator-billed accounts. + +**Example capabilities:** + +```json +{ + "account": { + "require_operator_auth": false, + "supported_billing": ["operator", "agent"] + } +} +``` + +Sellers that support `advertiser` billing declare it explicitly: + +```json +{ + "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 | `require_operator_auth` | `supported_billing` | +|---------------|------------------------|-------------------| +| [Social / walled garden](#social-platform) | `true` | `["operator"]` | +| [Direct publisher](#direct-publisher) | `false` | `["operator"]` or `["operator", "agent"]` | +| [DSP / programmatic](#dsp--programmatic) | `false` | `["agent"]` | + +### Social platform + +The operator already has an account on the platform — an ad account, a business manager, a self-serve dashboard. The agent obtains the operator's credentials (via OAuth or API key) and opens a per-operator session. The platform bills the operator directly. + +**Capabilities:** + +```json +{ + "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 `sync_accounts` to set up each brand for this operator +3. Wait for account status `active` (poll `list_accounts` if `pending_approval`) +4. Call `get_products` / `create_media_buy` with the operator's session and `account` reference + +**sync_accounts request:** + +```json +{ + "accounts": [{ + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com", + "billing": "operator" + }] +} +``` + +Seller checks `nova-brands.com/.well-known/brand.json`, finds Pinnacle Media in `authorized_operators`, and fast-tracks provisioning: + +```json +{ + "accounts": [{ + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com", + "action": "created", + "status": "active", + "billing": "operator", + "account_scope": "operator_brand" + }] +} +``` + +**Key point:** The operator's credential — not the agent's — authorizes all calls in that session. Brand.json verification is secondary to the credential. + +### 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 +{ + "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 +{ + "accounts": [{ + "brand": { "domain": "acme-corp.com" }, + "operator": "acme-corp.com", + "billing": "operator" + }] +} +``` + +Seller acknowledges the request but requires setup before provisioning: + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "accounts": [{ + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com", + "billing": "agent" + }] +} +``` + +Account active immediately: + +```json +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/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 which model applies — and the model determines the buyer's entire integration path. + + +Sellers that support caller-scope introspection attach an optional `authorization` object to each per-account entry in [`sync_accounts`](/dist/docs/3.0.13/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.0.13/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.0.13/accounts/overview#caller-authorization) for the full shape and semantics. + + +### Explicit accounts (`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 agent discovers the account via `list_accounts`. The agent is not involved in authentication or billing — those are handled between the advertiser and seller directly. + +**Typical sellers:** Social platforms, self-serve ad platforms — anywhere the advertiser already has an account. + +**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.) + +The agent doesn't set up accounts, negotiate billing, or manage authentication with the seller. It just discovers what already exists and lets the human choose. + +### Implicit 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. + +**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 implicit accounts; explicit sandbox accounts are pre-existing. | + +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: + - **Implicit accounts** (`require_operator_auth: false`): pass the natural key `{ "brand": { "domain": "acme-corp.com" }, "operator": "pinnacle-media.com" }` + - **Explicit accounts** (`require_operator_auth: true`): pass `{ "account_id": "acc_acme_001" }` (discover via `list_accounts`) + - **Sandbox (implicit)**: pass the natural key with `sandbox: true` (declared via `sync_accounts`) + - **Sandbox (explicit)**: pass `{ "account_id": "test_acc_001" }` (pre-existing test account, discovered via `list_accounts`) +4. When anything changes (billing model, new brand, new operator), call `sync_accounts` again + +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.0.13/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` | The agent's default account | Agent sends any brand — seller routes to the standing agent 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. + +`sync_accounts` does not return `account_id` — the seller manages account identifiers internally. For explicit accounts (`require_operator_auth: true`), discover account IDs via `list_accounts` — including sandbox test accounts. For implicit accounts (`require_operator_auth: false`), use natural keys (`brand` + `operator`) on subsequent requests — adding `sandbox: true` for sandbox 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 +{ + "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 +{ + "house": { + "domain": "local-bakery.com" + }, + "authoritative_location": "https://registry.agenticadvertising.org/brands/local-bakery.com" +} +``` diff --git a/dist/docs/3.0.13/building/by-layer/L2/authentication.mdx b/dist/docs/3.0.13/building/by-layer/L2/authentication.mdx new file mode 100644 index 0000000000..55d73fbc35 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L2/authentication.mdx @@ -0,0 +1,349 @@ +--- +title: Authentication +description: "AdCP authentication guide: public vs authenticated operations, bearer token implementation, and credential management for buyer and seller agents." +"og:title": "AdCP — Authentication" +--- + + +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.0.13/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. | +| **Bearer tokens** | Permitted; effective baseline for 3.0 | **PROHIBITED for mutating / financial operations**; permitted for read / discovery only | Documented sunset for mutating ops — see the [known limitation](/dist/docs/3.0.13/reference/known-limitations). | + + + **3.0 mutating-operation floor.** Until 3.1 lands, Bearer tokens 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. + + +### Bearer tokens (3.0 baseline) + +``` +Authorization: Bearer +``` + +Tokens may be: +- **Opaque tokens**: Server-validated strings mapped to agents +- **JWT tokens**: Self-contained tokens with embedded claims + +Implementations MUST enforce TLS 1.2+ on all Bearer-authenticated endpoints. See the [implementation security reference](/dist/docs/3.0.13/building/by-layer/L1/security) for transport requirements. + +The credential MUST be carried in the `Authorization` request header per [RFC 6750 §2](https://www.rfc-editor.org/rfc/rfc6750#section-2). 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` MUST also be accepted on the same endpoint. Buyer agents and SDKs MUST emit `Authorization: Bearer`; 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.0.13/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 +{ + "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.0.13/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.0.13/core/account.json). + +## Tenant resolution + +AdCP resolves tenant from the authenticated principal, not from request payloads. Seller agents map the authenticated identity (bearer token, 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.0.13/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: + +- **Bearer tokens over HTTP** — `Authorization: Bearer ` per [RFC 6750 §2](https://www.rfc-editor.org/rfc/rfc6750#section-2). +- **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. A seller that detects a credential-shaped key in the payload (e.g., `_access_token`, `api_key`, `client_secret`, `bearer`, or `authorization` at any nesting depth) SHOULD reject the request with [`CREDENTIAL_IN_ARGS`](/dist/docs/3.0.13/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.0.13/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.0.13/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 + +Both MCP and A2A use `Authorization: Bearer ` ([RFC 6750 §2](https://www.rfc-editor.org/rfc/rfc6750#section-2)) as the authentication header. Configure your client with: + +```json +{ + "auth": { + "type": "bearer", + "token": "" + } +} +``` + +The client library handles adding the `Authorization: Bearer ` header to requests. + +**Header alias policy by leg.** The two protocol legs differ in what aliases they accept beyond the standard header: + +- **A2A** — `Authorization: Bearer ` only. The `x-adcp-auth` custom header is not recognized on the A2A surface; the seller's agent card declares a `bearerAuth` `HTTPAuthSecurityScheme` (see the [A2A guide — Agent Cards](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide#agent-cards)). Sending `x-adcp-auth` on the A2A leg returns HTTP 401. +- **MCP** — `Authorization: Bearer ` (primary). `x-adcp-auth` is accepted as a back-compat 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 +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 +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 +# 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.0.13/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 +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.0.13/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 +{ + "error": { + "code": "AUTH_REQUIRED", + "message": "Authentication required for this operation" + } +} +``` + +### Invalid or Expired Credentials + +```json +{ + "error": { + "code": "AUTH_INVALID", + "message": "Invalid or expired credentials" + } +} +``` + +### Insufficient Permissions + +```json +{ + "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 +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/sales/mcp" +``` + +Configure your client with this token: + +```json +{ + "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.0.13/media-buy/advanced-topics/sandbox) for testing capabilities including sandbox mode for risk-free development. \ No newline at end of file diff --git a/dist/docs/3.0.13/building/by-layer/L2/context-sessions.mdx b/dist/docs/3.0.13/building/by-layer/L2/context-sessions.mdx new file mode 100644 index 0000000000..fc5cf19633 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L2/context-sessions.mdx @@ -0,0 +1,299 @@ +--- +title: Context & Sessions +description: "AdCP context_id vs task_id explained. How to manage conversation state, session continuity, and extension fields across MCP and A2A protocol requests." +"og:title": "AdCP — Context & Sessions" +--- + +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 +// 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 +// 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 +// 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 +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 +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 +{ + "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 +// ✅ 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 +{ + "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 +{ + "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 diff --git a/dist/docs/3.0.13/building/by-layer/L2/index.mdx b/dist/docs/3.0.13/building/by-layer/L2/index.mdx new file mode 100644 index 0000000000..44a9674b7c --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L2/index.mdx @@ -0,0 +1,26 @@ +--- +title: L2 — Auth & registry +sidebarTitle: L2 — Auth & registry +description: "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." +"og:title": "AdCP — L2 (Auth & registry)" +--- + +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.0.13/building/cross-cutting/sdk-stack#l2--auth--registry). + +## Pages in this layer + +- **[Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication)** — credentials and permissions. +- **[Account state](/dist/docs/3.0.13/building/by-layer/L2/account-state)** — multi-tenant account resolution. +- **[Accounts and agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents)** — relationship between account scoping and agent identity. +- **[Context & sessions](/dist/docs/3.0.13/building/by-layer/L2/context-sessions)** — managing principal state across requests. diff --git a/dist/docs/3.0.13/building/by-layer/L3/async-operations.mdx b/dist/docs/3.0.13/building/by-layer/L3/async-operations.mdx new file mode 100644 index 0000000000..02cfd51b53 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L3/async-operations.mdx @@ -0,0 +1,347 @@ +--- +title: Async Operations +description: "AdCP async operations guide: handling synchronous, asynchronous, and interactive (input-required) task types with polling, SSE streaming, and timeout strategies." +"og:title": "AdCP — Async Operations" +--- + +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 — configure a webhook via `push_notification_config`. 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 should configure a webhook and move on. + +:::tip Webhooks for `submitted` operations +**Webhooks** are the recommended approach for `submitted` operations — they work with any transport (MCP, A2A, REST) and handle operations that outlive a single session. See [Push Notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks). + +**Polling** via `tasks/get` works as a simpler alternative or backup. See the [polling pattern](#polling-for-submitted-operations) below. + +**MCP Tasks** handle async at the protocol level, but client support is still limited — most chat-based MCP clients (Claude Desktop, Cursor) don't yet support task-augmented tool calls. If you're building your own MCP client or using the JS SDK directly, MCP Tasks work well. See [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide#async-operations-via-mcp-tasks). +::: + +## 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 | +| `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. + +## Timeout Configuration + +Set reasonable timeouts based on status: + +```javascript +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 webhook delivery window — if you're also polling as backup, use a 30-second interval. + +## 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.0.13/governance/embedded-human-judgment) framework — human judgment is embedded in system design, not bolted on afterward. + +### Approval Patterns + +```javascript +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 +{ + "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 +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 +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 +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 — need webhook or polling + if (response.status === 'submitted') { + // Poll as backup (webhook is preferred — see Push Notifications) + 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 a backup for `submitted` operations when webhooks aren't configured or as a fallback. Don't poll for `working` — the server delivers the result on the open connection. + +```javascript +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('tasks/get', { + 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 +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 +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('tasks/get', { + 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.0.13/building/by-layer/L3/webhooks) for push notifications instead of polling +- **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) for status handling details +- **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design) for production patterns diff --git a/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller.mdx b/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller.mdx new file mode 100644 index 0000000000..c25e1032fa --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller.mdx @@ -0,0 +1,756 @@ +--- +title: Compliance test controller +description: "Optional sandbox tool that lets the storyboard runner walk full lifecycle state machines by triggering seller-side transitions deterministically." +"og:title": "AdCP — Compliance test controller" +--- + +# Compliance test controller + + +**The compliance test controller is a dev/staging-only affordance, not a production-time concept.** AAO grading does NOT require or use it. The AAO compliance heartbeat drives storyboards against the seller's registered production URL with `account.sandbox: true` on every request, and the seller's prod stack is responsible for honoring the flag — no controller endpoint needed. + +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 AAO 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 *your* testing, not the AAO-side 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.0.13/compliance/comply-test-controller-request.json) | [`comply-test-controller-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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_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" + ], + "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_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_task_completion: {task_id, result}. force_session_status: {session_id, status, termination_reason?}. simulate_delivery: {media_buy_id, impressions?, clicks?, reported_spend?, conversions?}. simulate_budget_spend: {account_id|media_buy_id, spend_percentage}. 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?}." + } + }, + "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.0.13/creative/specification#creative-status-lifecycle). + +**Params:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `creative_id` | string | Yes | Creative to transition | +| `status` | `processing` \| `approved` \| `rejected` \| `pending_review` \| `archived` | Yes | Target status | +| `rejection_reason` | string | When `status` = `rejected` | Reason for rejection | + +**Example:** + +```json +{ + "scenario": "force_creative_status", + "params": { + "creative_id": "cr-123", + "status": "rejected", + "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.0.13/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 +{ + "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 +{ + "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.0.13/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.0.13/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 +{ + "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 +{ + "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_task_completion` + +Resolves a previously-submitted async task to `completed` with a buyer-supplied result payload. The companion to `force_create_media_buy_arm`: that scenario drives the seller into the 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` (the canonical 3.0 delivery path for completion payloads) and via subsequent [`tasks/get`](/dist/docs/3.0.13/building/by-layer/L3/async-operations#polling-for-submitted-operations) calls reporting `status: "completed"`. A typed result projection on the polling response is tracked for 3.1 in [#3123](https://github.com/adcontextprotocol/adcp/issues/3123). + +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.0.13/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 +{ + "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 +{ + "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 +{ + "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 | + +**Example:** + +```json +{ + "scenario": "simulate_delivery", + "params": { + "media_buy_id": "mb-789", + "impressions": 10000, + "clicks": 150, + "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 +{ + "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. | + +**Example:** + +```json +{ + "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.0.13/core/pricing-option.json) schema (`pricing_model`, `currency`, `floor_price` for auction-based, `fixed_price` for fixed, etc.) | + +**Example:** + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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" }] + } + } +} +``` + +### 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`). 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 +{ + "success": true, + "previous_state": "processing", + "current_state": "approved", + "message": "Creative cr-123 transitioned from processing to approved" +} +``` + +**Failure (invalid transition):** + +```json +{ + "success": false, + "error": "INVALID_TRANSITION", + "error_detail": "Cannot transition from archived to processing — archived is terminal", + "current_state": "archived" +} +``` + +**Failure (unknown entity):** + +```json +{ + "success": false, + "error": "NOT_FOUND", + "error_detail": "Creative cr-unknown not found", + "current_state": null +} +``` + +### Simulation responses (`simulate_*`) + +**`simulate_delivery` response:** + +```json +{ + "success": true, + "simulated": { + "impressions": 10000, + "clicks": 150, + "reported_spend": { "amount": 150.00, "currency": "USD" } + }, + "cumulative": { + "impressions": 25000, + "clicks": 380, + "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 all simulation calls for this media buy, so callers can verify expected state before checking `get_media_buy_delivery`. + +**`simulate_budget_spend` response:** + +```json +{ + "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 | +| `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.0.13/compliance/comply-test-controller-response.json), distinct from the canonical seller-response [`error-code.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 +{ + "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.0.13/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.0.13/creative/specification#creative-status-lifecycle), [account lifecycle](/dist/docs/3.0.13/accounts/overview#account-status-lifecycle), [media buy lifecycle](/dist/docs/3.0.13/media-buy/specification), [SI session lifecycle](/dist/docs/3.0.13/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. diff --git a/dist/docs/3.0.13/building/by-layer/L3/error-handling.mdx b/dist/docs/3.0.13/building/by-layer/L3/error-handling.mdx new file mode 100644 index 0000000000..040eee7e61 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L3/error-handling.mdx @@ -0,0 +1,901 @@ +--- +title: Error Handling +description: "AdCP error handling: protocol errors, task failures, and validation errors with standard error codes, recovery strategies, and exponential backoff retry logic." +"og:title": "AdCP — Error Handling" +--- + +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.0.13/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.0.13/core/error.json) schema: + +```json +{ + "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.0.13/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.0.13/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 +// 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 +// 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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 | Buyer-principal credential was placed in request args (top-level, `context`, `ext`, or any nested location) instead of arriving on the transport's authentication channel | Do NOT auto-retry — auto-retry re-logs the credential. Move the credential onto the transport channel ([Credential placement](/dist/docs/3.0.13/building/by-layer/L2/authentication#credential-placement)), rotate the 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 the buyer placed a credential in the **task payload** instead of the transport's authentication 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.0.13/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 +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/accounts/tasks/sync_accounts)), not the `@adcp/sdk/testing` wrapper used elsewhere in task examples. + +```javascript +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 +{ + "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.0.13/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.0.13/accounts/tasks/list_accounts) responses. See [Caller authorization](/dist/docs/3.0.13/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.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/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 +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 +{ + "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 +{ + "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.0.13/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 | Call `get_products` with `buying_mode: "refine"` against the existing `proposal_id`, then resubmit against the new `proposal_id` | +| `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 +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.0.13/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.0.13/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 +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 +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 +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 +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 +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 +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 +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 +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 +{ + "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 +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.0.13/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.0.13/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 +{ + "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 +{ + "code": "CONFIGURATION_ERROR", + "message": "configuration error", + "recovery": "terminal" +} +``` + +Leaks (don't): + +```json +{ + "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.0.13/building/operating/transport-errors) for how errors travel over MCP and A2A +- **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) for status handling +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for webhook error handling +- **Security**: See [Security](/dist/docs/3.0.13/building/by-layer/L1/security) for authentication errors diff --git a/dist/docs/3.0.13/building/by-layer/L3/index.mdx b/dist/docs/3.0.13/building/by-layer/L3/index.mdx new file mode 100644 index 0000000000..da6b171a6b --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L3/index.mdx @@ -0,0 +1,32 @@ +--- +title: L3 — Protocol semantics +sidebarTitle: L3 — Protocol semantics +description: "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." +"og:title": "AdCP — L3 (Protocol semantics)" +--- + +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.0.13/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.0.13/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.0.13/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0). + +## Pages in this layer + +- **[Task lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** — status values, transitions, polling. +- **[Async operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations)** — sync, async, and interactive task handling. +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** — push notifications, signing, retry, idempotency. +- **[Error handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** — error categories, codes, recovery classification. +- **[`comply_test_controller`](/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller)** — sandbox-only conformance test surface. diff --git a/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle.mdx b/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle.mdx new file mode 100644 index 0000000000..118513fc1c --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle.mdx @@ -0,0 +1,270 @@ +--- +title: Task Lifecycle +description: "AdCP task lifecycle: status values (submitted, working, input-required, completed, failed), state transitions, response structure, and polling patterns for all operations." +"og:title": "AdCP — Task Lifecycle" +--- + +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 Transport-specific task management +The status values and lifecycle described here are transport-independent — they apply regardless of how you access AdCP. The *mechanism* for tracking async tasks varies by transport: +- **MCP**: Use [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) — the client polls via `tasks/get` and retrieves results via `tasks/result` at the protocol level. See [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide#async-operations-via-mcp-tasks). +- **A2A**: Use native A2A task lifecycle with SSE streaming. See [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide). +- **REST**: Use AdCP's `task_id` with polling or [push notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks). +::: + +## 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 +{ + "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 +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 +{ + "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 +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.0.13/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 +{ + "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 +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 +{ + "status": "submitted", + "message": "Media buy submitted for publisher approval", + "context_id": "ctx-123", + "task_id": "task-456" +} +``` + +**Transport-specific handling for `submitted` operations:** +- **MCP**: Use [MCP Tasks](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide#async-operations-via-mcp-tasks) or poll via `tasks/get` +- **A2A**: Subscribe to SSE stream for real-time updates +- **REST**: Use [push notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks) (recommended) or poll with `task_id` + +## 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.0.13/building/by-layer/L3/webhooks)); `include_result` is the typed polling alternative for buyers that prefer pull over push. + +```javascript +// Polling is only for 'submitted' operations +async function pollForResult(taskId, pollInterval = 30_000) { + while (true) { + await sleep(pollInterval); + + const response = await adcp.call('tasks/get', { + 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 +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 `tasks/list` to recover from lost state: + +```javascript +// Find all pending operations +const pending = await session.call('tasks/list', { + 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.0.13/building/by-layer/L3/async-operations) for handling different operation types +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for push notification patterns +- **Error Handling**: See [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) for error categories and recovery diff --git a/dist/docs/3.0.13/building/by-layer/L3/webhooks.mdx b/dist/docs/3.0.13/building/by-layer/L3/webhooks.mdx new file mode 100644 index 0000000000..531cd55fe1 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L3/webhooks.mdx @@ -0,0 +1,584 @@ +--- +title: Push Notifications +description: "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." +"og:title": "AdCP — Push Notifications" +--- + +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 +2. A webhook URL is built by substituting that ID (and other routing params) into a URL template +3. `push_notification_config` is injected into the task request body with just the URL — no secret required +4. The seller POSTs webhook notifications to your URL as the task status changes, signing each POST with its `adcp_use: "webhook-signing"` key published in its own brand.json `agents[]` entry +5. You verify the signature against the seller's published JWKS and dedupe by `idempotency_key` +6. Each notification echoes `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/cd51e063-2b79-4a6d-afac-ed7789c3a443" + // 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/cd51e063-2b79-4a6d-afac-ed7789c3a443 + 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": "cd51e063-2b79-4a6d-afac-ed7789c3a443", ← echoed from your URL + "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 webhook-signing JWK at your brand.json `agents[]` entry (with `adcp_use: "webhook-signing"`) and the client signs outgoing webhooks for you. + +:::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.0.13/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: ... } }` | +| **A2A configuration object** | `pushNotificationConfig` | `configuration: { pushNotificationConfig: { url: ... } }` | + +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 +{ + "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/op_abc123" + } +} +``` + +`authentication` is omitted in the default case — the seller signs with its own `adcp_use: "webhook-signing"` key. 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 +{ + "message": { + "parts": [{ + "kind": "data", + "data": { + "skill": "create_media_buy", + "parameters": { + "packages": [...] + } + } + }] + }, + "configuration": { + "pushNotificationConfig": { + "url": "https://you.com/webhooks/adcp/create_media_buy/op_abc123" + } + } +} +``` + +## Operation IDs and URL templates + +Operation IDs let you route incoming webhooks to the right handler. The pattern: + +1. Buyer generates a unique ID per task call +2. Buyer threads it through to the seller as `operation_id` (typically by embedding in the webhook URL path, but the 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 MUST** supply `operation_id` to the seller for every webhook registration and SHOULD generate it as a unique value per task invocation (UUID recommended). +- **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 MUST NOT** correlate webhooks by URL-path inspection in production code. The URL is for buyer-side server routing convenience only; 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}/{operation_id} +``` + +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 `?op=…`, 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 +import { randomUUID } from 'crypto'; + +const operationId = randomUUID(); // e.g. "cd51e063-2b79-4a6d-afac-ed7789c3a443" +const webhookUrl = `https://you.com/adcp/webhook/create_media_buy/${agentId}/${operationId}`; + +// pass webhookUrl in push_notification_config.url +``` + +The seller's webhook payload will include `"operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443"`, so your handler can route to the right pending operation by reading the payload field directly — never by parsing the URL it arrived on. + +**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.0.13/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 `completed` or `failed`), no webhook is sent — you already have the result. + +**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 +{ + "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. + +### 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 +{ + "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.0.13/building/by-layer/L1/security#webhook-callbacks). The seller signs with its `adcp_use: "webhook-signing"` key published in its own brand.json `agents[]` entry; 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.0.13/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.0.13/building/by-layer/L1/security#webhook-error-taxonomy)). +- `tag` MUST be `adcp/webhook-signing/v1`. +- Resolve `keyid` via the seller's `adagents.json` `agents[]` entry (you already have the seller's agent URL from your integration). + +**Receiver implementation sketch:** +```typescript +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's adagents.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 +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.0.13/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 +{ + "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.0.13/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 +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 +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.0.13/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 +{ + "authentication": { + "schemes": ["Bearer"], + "credentials": "your_bearer_token_min_32_chars" + } +} +``` + +```javascript +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.0.13/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 +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.0.13/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 routing, not URL parsing +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.0.13/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 +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.0.13/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.0.13/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.0.13/core/mcp-webhook-payload.json) and [snapshot-and-log Rule 1](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/task-lifecycle) — status values and transitions +- [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) — handling long-running tasks +- [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) — webhook error patterns diff --git a/dist/docs/3.0.13/building/by-layer/L4/build-a-caller.mdx b/dist/docs/3.0.13/building/by-layer/L4/build-a-caller.mdx new file mode 100644 index 0000000000..a750f22f4e --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L4/build-a-caller.mdx @@ -0,0 +1,228 @@ +--- +title: Build a caller +sidebarTitle: Build a caller +description: "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." +"og:title": "AdCP — Build a caller" +--- + +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.0.13/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.0.13/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 +npm install @adcp/sdk +``` + +```typescript +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 +pip install adcp +``` + +```python +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 +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 +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 +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.0.13/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 +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.0.13/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.0.13/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 +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.0.13/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.0.13/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 +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 tasks/get. +} 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 `tasks/get` directly via the underlying transport. The [Async responses section of Calling an agent](/dist/docs/3.0.13/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 +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.0.13/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 a `tasks/get` 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.0.13/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.0.13/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 +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.0.13/building/cross-cutting/sdk-stack) decomposes each layer; the [server-vs-client comparison table](/dist/docs/3.0.13/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.0.13/protocol/calling-an-agent)** — the canonical wire-contract reference. Read once before going to production. +- **[Schemas](/dist/docs/3.0.13/building/by-layer/L0/schemas)** — schema bundle, type generation, version pinning. +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** — push notifications, signing, retry, reliability patterns. +- **[Error handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** — error categories, codes, recovery classification. +- **[Validate your agent](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/index) +- [Creative task reference](/dist/docs/3.0.13/creative/task-reference) +- [Signals task reference](/dist/docs/3.0.13/signals/tasks/get_signals) +- [Brand protocol tasks](/dist/docs/3.0.13/brand-protocol) diff --git a/dist/docs/3.0.13/building/by-layer/L4/build-an-agent.mdx b/dist/docs/3.0.13/building/by-layer/L4/build-an-agent.mdx new file mode 100644 index 0000000000..e626cbc28d --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L4/build-an-agent.mdx @@ -0,0 +1,218 @@ +--- +title: Build an Agent +sidebarTitle: Build an Agent +description: "Use AdCP SDK skill files to generate storyboard-compliant agents with a coding agent in minutes." +"og:title": "AdCP — Build an Agent" +--- + +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.0.13/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 +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 +pip install adcp +``` + +The Python SDK provides the same capabilities — subclass `ADCPHandler`, implement tools, and use response builders for every return value: + +```python +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 +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.0.13/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.0.13/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.0.13/brand-protocol) and claim `brand-rights` under the `brand` domain. + +See the [Compliance Catalog](/dist/docs/3.0.13/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.0.13/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 +# 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.0.13/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.0.13/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.0.13/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.0.13/building/verification/validate-your-agent)** — Storyboards, compliance checks, and the build-validate-fix loop +- **[Operating an Agent](/dist/docs/3.0.13/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.0.13/building/by-layer/L4/choose-your-sdk)** — Schema access, CLI tools, and SDK package exports +- **[MCP integration guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide)** — Transport, sessions, and auth details +- **[Task lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling +- **[Error handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery diff --git a/dist/docs/3.0.13/building/by-layer/L4/choose-your-sdk.mdx b/dist/docs/3.0.13/building/by-layer/L4/choose-your-sdk.mdx new file mode 100644 index 0000000000..069a1e6ff6 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L4/choose-your-sdk.mdx @@ -0,0 +1,150 @@ +--- +title: Choose your SDK +sidebarTitle: Choose your SDK +description: "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." +"og:title": "AdCP — Choose your SDK" +--- + +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.0.13/building/cross-cutting/sdk-stack). + +## Coverage matrix + +What "shipped" means at each layer is the [L0–L3 checklist](/dist/docs/3.0.13/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.0.13/reference/migration/index) before upgrading. For the at-a-glance status of every published protocol version, see [Versions & Compatibility](/dist/docs/3.0.13/reference/versions). For procurement-grade context on the support window, see the [v2 sunset timeline](/dist/docs/3.0.13/reference/v2-sunset) and [versioning & governance](/dist/docs/3.0.13/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.0.13/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 +npm install @adcp/sdk +``` + +```javascript +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.0.13/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 +pip install adcp +``` + +```python +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 +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 +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 +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.0.13/building/by-layer/L4/build-an-agent)** — server-side L4 path. Skill files + coding agent. +- **[Build a caller](/dist/docs/3.0.13/building/by-layer/L4/build-a-caller)** — client-side L4 path. Install, call, handle responses, ingest reporting. +- **[Schemas](/dist/docs/3.0.13/building/by-layer/L0/schemas)** — schema bundle, type generation, version pinning. +- **[Migrate from hand-rolled](/dist/docs/3.0.13/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. diff --git a/dist/docs/3.0.13/building/by-layer/L4/index.mdx b/dist/docs/3.0.13/building/by-layer/L4/index.mdx new file mode 100644 index 0000000000..5a25ebbff2 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L4/index.mdx @@ -0,0 +1,17 @@ +--- +title: L4 — Business logic +sidebarTitle: L4 — Business logic +description: "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." +"og:title": "AdCP — L4 (Business logic)" +--- + +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/building/by-layer/L4/migrate-from-hand-rolled.mdx b/dist/docs/3.0.13/building/by-layer/L4/migrate-from-hand-rolled.mdx new file mode 100644 index 0000000000..e546c51e44 --- /dev/null +++ b/dist/docs/3.0.13/building/by-layer/L4/migrate-from-hand-rolled.mdx @@ -0,0 +1,224 @@ +--- +title: Migrate from a hand-rolled agent +sidebarTitle: Migrate from hand-rolled +description: "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." +"og:title": "AdCP — Migrate from a hand-rolled agent" +--- + +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.0.13/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.0.13/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.0.13/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.0.13/building/verification/conformance) and [Validate Your Agent](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/by-layer/L3/task-lifecycle). +5. **State machines**, one resource at a time. MediaBuy first ([lifecycle reference](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/webhooks). +7. **RFC 9421 signing + verification**. Independent of everything above; can be parallelized. See [Security implementation](/dist/docs/3.0.13/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.0.13/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.0.13/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 +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.5', // ← canonical / current +}]); +``` + +Your outbound (server) side, declaring what *you* accept: + +```ts +import { createAdcpServer } from '@adcp/sdk/server'; + +createAdcpServer({ + capabilities: { + major_versions: [3], + supported_versions: ['3.0.5'], + }, + // …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.0.13/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.0.13/building/cross-cutting/sdk-stack) — layered architecture reference +- [Where to start](/dist/docs/3.0.13/building) — decision page +- [Version Adaptation](/dist/docs/3.0.13/building/cross-cutting/version-adaptation) — three-mechanism reference +- [Conformance](/dist/docs/3.0.13/building/verification/conformance) — how the storyboard suite grades your agent +- [Build an Agent](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) — greenfield path; useful as a reference for what the L4-on-SDK end state looks like diff --git a/dist/docs/3.0.13/building/compliance-catalog.mdx b/dist/docs/3.0.13/building/compliance-catalog.mdx new file mode 100644 index 0000000000..28913934e5 --- /dev/null +++ b/dist/docs/3.0.13/building/compliance-catalog.mdx @@ -0,0 +1,228 @@ +--- +title: Compliance Catalog +sidebarTitle: Compliance Catalog +description: "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." +"og:title": "AdCP — Compliance Catalog" +--- + +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.0.13/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. + +{/* Lint: scripts/lint-universal-storyboard-doc-parity.cjs keeps this table in sync with static/compliance/source/universal/. Add a row (kebab-case slug) when you add a graded storyboard; remove the row when one is deleted. The build fails on drift. */} + +| Storyboard | Purpose | +|-----------|---------| +| `capability-discovery` | `get_adcp_capabilities` shape, protocol/specialism declarations, version advertising | +| `schema-validation` | Request and response schema conformance, ISO 8601 timestamps, temporal invariants | +| `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 | +| `idempotency` | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | +| `security` | **Authentication baseline — unauth rejection, API key enforcement, OAuth discovery + RFC 9728 audience binding.** See [Authentication](/dist/docs/3.0.13/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. | +| `pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_creatives` response from a continuation page through to terminal. | +| `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 catalog, 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`. | +| `comply-controller-mode-gate` | `comply_test_controller` live-mode denial gate — verifies sellers return `FORBIDDEN` when a live-mode account calls the controller; skipped for agents that do not expose `comply_test_controller`. | +| `signed-requests` | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. | + +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. + +## 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.0.13/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 | + + +Support for the [compliance test controller](/dist/docs/3.0.13/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` | stable | Media buys negotiated via proposal acceptance | +| `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 — accepts `sync_governance`, calls `check_governance`, and propagates approvals, conditions, and denials unchanged. Optional claim; sellers that don't claim it skip the governance scenarios as not_applicable. | +| `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 | +| `signal-marketplace` | stable | Marketplace signal agent reselling third-party data | + +### 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 | + + +**Coming in 3.1.** `measurement-verification` (third-party viewability, attribution, brand-safety, and SI-outcome verification) is scheduled for 3.1 under a dedicated `measurement` protocol. + + +### brand + +| Specialism | Status | Purpose | +|-----------|--------|---------| +| `brand-rights` | stable | Brand identity and rights licensing (talent, music, stock media) | + +## How to claim + +Declare your protocols and specialisms in `get_adcp_capabilities`: + +```json +{ + "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.0.13/building/verification/validate-your-agent) for how to run the suite locally. + +## 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 agent builds the segment on demand from models, composites, or buyer-supplied inputs. Use this when no `adagents.json` authorization chain applies — the segment is agent-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`. + diff --git a/dist/docs/3.0.13/building/concepts/adcp-vs-openrtb.mdx b/dist/docs/3.0.13/building/concepts/adcp-vs-openrtb.mdx new file mode 100644 index 0000000000..43e3ed29fe --- /dev/null +++ b/dist/docs/3.0.13/building/concepts/adcp-vs-openrtb.mdx @@ -0,0 +1,122 @@ +--- +title: AdCP and OpenRTB +sidebarTitle: AdCP vs OpenRTB +description: "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." +"og:title": "AdCP — AdCP and OpenRTB" +--- + +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.0.13/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. + +**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.0.13/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 +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. + + + diff --git a/dist/docs/3.0.13/building/concepts/how-agents-communicate.mdx b/dist/docs/3.0.13/building/concepts/how-agents-communicate.mdx new file mode 100644 index 0000000000..7c08e4c510 --- /dev/null +++ b/dist/docs/3.0.13/building/concepts/how-agents-communicate.mdx @@ -0,0 +1,180 @@ +--- +title: How AI agents communicate ad specs across platforms +sidebarTitle: How agents communicate +description: "How AI advertising agents discover inventory, exchange campaign data, and execute buys across platforms using AdCP's standardized task schemas and MCP transport." +"og:title": "AdCP — How AI agents communicate ad specs across platforms" +--- + +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 +{ + "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 +{ + "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 +{ + "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. + + diff --git a/dist/docs/3.0.13/building/concepts/index.mdx b/dist/docs/3.0.13/building/concepts/index.mdx new file mode 100644 index 0000000000..9ffd6ceda4 --- /dev/null +++ b/dist/docs/3.0.13/building/concepts/index.mdx @@ -0,0 +1,108 @@ +--- +title: Why AdCP +description: "Why AdCP exists: the fragmentation problem across RTB, platform APIs, and direct IO — and how a universal agent protocol for advertising solves it." +"og:title": "AdCP — Why AdCP" +--- + +## 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 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. + + diff --git a/dist/docs/3.0.13/building/concepts/industry-landscape.mdx b/dist/docs/3.0.13/building/concepts/industry-landscape.mdx new file mode 100644 index 0000000000..4018ef5e57 --- /dev/null +++ b/dist/docs/3.0.13/building/concepts/industry-landscape.mdx @@ -0,0 +1,140 @@ +--- +title: AI advertising standards landscape +sidebarTitle: Industry landscape +description: "AI advertising standards landscape: how AdCP, OpenRTB, MCP, and A2A relate. Comparison of protocols, standards bodies, and their roles in agentic advertising." +"og:title": "AdCP — AI advertising standards landscape" +--- + +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.0.13/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. + + diff --git a/dist/docs/3.0.13/building/concepts/protocol-comparison.mdx b/dist/docs/3.0.13/building/concepts/protocol-comparison.mdx new file mode 100644 index 0000000000..e1baaf652a --- /dev/null +++ b/dist/docs/3.0.13/building/concepts/protocol-comparison.mdx @@ -0,0 +1,236 @@ +--- +title: Protocol Comparison +description: "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." +"og:title": "AdCP — Protocol Comparison" +--- + +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** | [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) | SSE streaming | +| **Webhooks** | `push_notification_config` in tool args | Native PushNotificationConfig | +| **Task Management** | MCP Tasks (`tasks/get`, `tasks/result`, `tasks/cancel`) | Native task lifecycle | +| **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 +{ + "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) | Poll frequently, 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.0.13/building/by-layer/L3/task-lifecycle) for complete status handling guide. + +## Transport Format Differences + +Same status and data, different packaging: + +### MCP Response Format +```json +{ + "status": "input-required", + "message": "I need your budget and target audience", + "context_id": "ctx-123", + "products": [], + "suggestions": ["budget", "audience"] +} +``` + +### A2A Response Format +```json +{ + "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 handle async operations with the same status progression: +`submitted` → `working` → `completed`/`failed` + +### MCP Async Pattern (MCP Tasks) +```javascript +// Task-augmented tool call — returns CreateTaskResult immediately +const createResult = await mcp.callTool({ + name: "create_media_buy", + arguments: { + buyer_ref: "nike_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 +const result = await mcp.getTaskResult({ taskId: "task-456" }); +// result = { content: [...], isError: false } +``` + +### A2A Async Pattern +```javascript +// Initial response with native task tracking +{ + "status": "submitted", + "taskId": "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 +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 +// 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 +// 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 +{ + "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.0.13/building/by-layer/L0/mcp-guide) for tool calls and context management +- **A2A Guide**: See [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) for artifacts and streaming +- **Both protocols**: Provide the same capabilities with unified status handling diff --git a/dist/docs/3.0.13/building/concepts/security-model.mdx b/dist/docs/3.0.13/building/concepts/security-model.mdx new file mode 100644 index 0000000000..6e5ce3ab82 --- /dev/null +++ b/dist/docs/3.0.13/building/concepts/security-model.mdx @@ -0,0 +1,311 @@ +--- +title: Security Model +sidebarTitle: Security Model +description: "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." +"og:title": "AdCP — Security Model" +--- + +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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 on the `adcp_use: "webhook-signing"` surface — durable seller-side acknowledgement flows through the same at-rest signing 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 systems 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.0.13/building/by-layer/L1/security#request-signing). Key purpose `adcp_use: "request-signing"`. +- **Outbound webhook signing.** Sellers sign asynchronous [webhook deliveries](/dist/docs/3.0.13/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: "webhook-signing"`. +- **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.0.13/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.0.13/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.0.13/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 surfaces with two purposes, not one surface with a 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.0.13/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 `adcp_use` purpose is another key 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 artefact in each of these flows rides the standard `adcp_use: "webhook-signing"` surface — 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 on the same `adcp_use: "webhook-signing"` surface — 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: "webhook-signing"`. Verification is uniform with every other at-rest seller→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.0.13/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.0.13/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.0.13/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.0.13/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** — publisher-attested in `brand.json` `agents[].signing_keys[]`, fetched from the publisher's own `/.well-known`. +- **`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.0.13/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[].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.0.13/building/by-layer/L1/security)** — Normative rules for HMAC, idempotency, SSRF, agent/account isolation, and governance verification +- **[Embedded Human Judgment](/dist/docs/3.0.13/governance/embedded-human-judgment)** — The five principles that keep humans in the loop on decisions with real consequences +- **[Trusted Match Protocol](/dist/docs/3.0.13/trusted-match)** — The two-call decomposition (Context Match / Identity Match) that delivers structural privacy separation at serve time +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** — Signature format, replay windows, rotation +- **[Signed Governance Context](/dist/docs/3.0.13/building/by-layer/L1/security#signed-governance-context)** — The 15-step verification checklist +- **[Operating an Agent](/dist/docs/3.0.13/building/operating/operating-an-agent)** — Credential management, monitoring, and incident response as operating concerns +- **[How Agents Communicate](/dist/docs/3.0.13/building/concepts/how-agents-communicate)** — `adagents.json`, `brand.json`, and the discovery trust chain diff --git a/dist/docs/3.0.13/building/conformance.mdx b/dist/docs/3.0.13/building/conformance.mdx new file mode 100644 index 0000000000..04492bc230 --- /dev/null +++ b/dist/docs/3.0.13/building/conformance.mdx @@ -0,0 +1,160 @@ +--- +title: Conformance Specification +sidebarTitle: Conformance Specification +description: "What 'AdCP-conformant' means, defined by the storyboards that verify it. Conformance is what the spec requires; verified is what the suite attests." +"og:title": "AdCP — Conformance Specification" +--- + +**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 against those storyboards and issued a signed attestation ([AAO Verified badge](/dist/docs/3.0.13/building/verification/compliance-catalog)). Gated on active membership and a live heartbeat. +- **"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 that an agent is conformant. +- Verified ⊆ Conformant. You can be conformant without being verified; you cannot be verified without being conformant. + +## 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 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.0.13/building/verification/compliance-catalog) for the full taxonomy and [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) for how to run the suite locally. + +## Universal conformance + +Every agent MUST pass every storyboard below. + +{/* Lint: scripts/lint-universal-storyboard-doc-parity.cjs keeps this table in sync with static/compliance/source/universal/. Add a row (snake_case YAML id) when you add a graded storyboard; remove the row when one is deleted. The build fails on drift. */} + +| Storyboard | What it verifies | +|------------|------------------| +| [`capability_discovery`](https://adcontextprotocol.org/compliance/latest/universal/capability-discovery) | `get_adcp_capabilities` shape, protocol/specialism declarations, version advertising | +| [`schema_validation`](https://adcontextprotocol.org/compliance/latest/universal/schema-validation) | Request and response schema conformance, ISO 8601 timestamps, temporal invariants | +| [`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 | +| [`idempotency`](https://adcontextprotocol.org/compliance/latest/universal/idempotency) | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | +| [`security_baseline`](https://adcontextprotocol.org/compliance/latest/universal/security) | Unauth rejection, API key enforcement, 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`. | +| [`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_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 signals catalog | +| [`pagination_integrity_list_accounts`](https://adcontextprotocol.org/compliance/latest/universal/pagination-integrity-list-accounts) | `cursor` ↔ `has_more` invariant on paginated `list_accounts` responses; storyboard bootstraps three accounts via `sync_accounts` and walks from a continuation page to a terminal page | +| [`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` | +| [`comply_controller_mode_gate`](https://adcontextprotocol.org/compliance/latest/universal/comply-controller-mode-gate) | `comply_test_controller` live-mode denial gate — verifies sellers return `FORBIDDEN` when a live-mode account calls the controller; skipped for agents that do not expose `comply_test_controller` | +| [`signed_requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests) | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. | + +Agents that declare `capabilities.compliance_testing.supported: true` MUST implement the full [test controller](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 + +- **[Storyboard troubleshooting](/dist/docs/3.0.13/building/operating/storyboard-troubleshooting)** — Error pattern → root cause → fix for the most common storyboard failures +- **[Known spec ambiguities](/dist/docs/3.0.13/building/cross-cutting/known-ambiguities)** — Open spec gaps with workarounds and issue links; entries are removed as underlying issues close + +## Further reading + +- **[Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog)** — Full taxonomy of protocols and specialisms with storyboard IDs +- **[Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent)** — How to run the suite +- **[Security Model](/dist/docs/3.0.13/building/concepts/security-model)** — Strategic framing for the five defense layers that the security storyboards enforce +- **[Security (implementation reference)](/dist/docs/3.0.13/building/by-layer/L1/security)** — Normative rules cited by the storyboards +- **[Versioning](/dist/docs/3.0.13/reference/versioning)** — Major-version support windows +- **[Known Limitations](/dist/docs/3.0.13/reference/known-limitations)** — Visible edges of the specification diff --git a/dist/docs/3.0.13/building/cross-cutting/known-ambiguities.mdx b/dist/docs/3.0.13/building/cross-cutting/known-ambiguities.mdx new file mode 100644 index 0000000000..d145419526 --- /dev/null +++ b/dist/docs/3.0.13/building/cross-cutting/known-ambiguities.mdx @@ -0,0 +1,80 @@ +--- +title: Known spec ambiguities +description: "Open AdCP spec gaps that affect compliance testing — with workarounds and issue links. Entries are removed as the underlying issues close." +"og:title": "AdCP — Known spec ambiguities" +--- + +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.0.13/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) — the storyboard narrative now explicitly directs API-key-only agents to declare `auth.api_key` in the test kit and omit PRM entirely. Optional-phase semantics make `oauth_discovery` failures non-fatal; `mechanism_required` passes via the API-key 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 key as a valid credential — the default test kit (`acme-outdoor`) probes with `demo-acme-outdoor-v1`, and your agent must accept that value alongside your production key. This satisfies `test_kit.auth.api_key` and lets the `api_key_path` phase run; without it the phase is skipped and `assert_mechanism` fails with `actual: []`. See the [storyboard troubleshooting guide — Bearer-only agent: assert_mechanism](/dist/docs/3.0.13/building/operating/storyboard-troubleshooting#bearer-only-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 — canonical example `universal/schema-validation.yaml > past_start_reject_path` + `past_start_adjust_path` (aggregation flag `past_start_handled`). +- **Gap**: a conformant agent picks one branch (e.g., rejects past `start_time`); the other branch's assertion (e.g., `field_present: media_buy_id`) 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. diff --git a/dist/docs/3.0.13/building/cross-cutting/sdk-stack.mdx b/dist/docs/3.0.13/building/cross-cutting/sdk-stack.mdx new file mode 100644 index 0000000000..5cc62d58fe --- /dev/null +++ b/dist/docs/3.0.13/building/cross-cutting/sdk-stack.mdx @@ -0,0 +1,340 @@ +--- +title: The AdCP stack +sidebarTitle: SDK stack reference +description: "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." +"og:title": "AdCP — The AdCP stack" +--- + + +**AdCP is the transaction and control plane** — planning, deal creation, creative submission, reporting. Impression-time decisioning happens in adjacent protocols ([TMP](/dist/docs/3.0.13/trusted-match), RTB, VAST). AdCP latency budgets are seconds, often async-by-design — not the millisecond budgets you'd expect from a serve-time protocol. If you came here looking for a serve-time auction surface, you want [TMP](/dist/docs/3.0.13/trusted-match). + + +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.0.13/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 +%%{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.0.13/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.0.13/building/concepts/security-model) and the [implementation profile](/dist/docs/3.0.13/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.0.13/accounts/overview) and [Calling an agent](/dist/docs/3.0.13/protocol/calling-an-agent). + +What's in it: + +- Agent registry lookup (resolving agent metadata from a published [agent card](/dist/docs/3.0.13/protocol/calling-an-agent)). +- Brand resolution: mapping the requesting agent to a buyer brand / advertiser identity via [Brand Protocol](/dist/docs/3.0.13/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.0.13/registry/registering-an-agent) and [AAO Verified](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/by-layer/L3/task-lifecycle). +- **Webhook emission** — state changes notify subscribed buyers, with retry, idempotency, and signature. See [Webhooks](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/comply-test-controller) and [Conformance](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/verification/conformance). +- **Conformance test surface** — storyboards drive your state via the [`comply_test_controller`](/dist/docs/3.0.13/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.0.13/building/cross-cutting/version-adaptation). For the spec-side rules, see [Versioning](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0). Re-evaluate against today's coverage, not the one you remember. diff --git a/dist/docs/3.0.13/building/cross-cutting/version-adaptation.mdx b/dist/docs/3.0.13/building/cross-cutting/version-adaptation.mdx new file mode 100644 index 0000000000..91bb66df99 --- /dev/null +++ b/dist/docs/3.0.13/building/cross-cutting/version-adaptation.mdx @@ -0,0 +1,203 @@ +--- +title: Version Adaptation +sidebarTitle: Version Adaptation +description: "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." +"og:title": "AdCP — Version Adaptation" +--- + +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.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 | + +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.0.13/building/cross-cutting/sdk-stack#version-adaptation). For the spec-side rules see [Versioning](/dist/docs/3.0.13/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 +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 +// 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 +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.0.13/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.5'`, `'3.1.0'` — 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. + +Example with `@adcp/sdk` (lower-level handler-bag API): + +```ts +import { createAdcpServer } from '@adcp/sdk/server'; + +const server = createAdcpServer({ + name: 'My Agent', + version: '1.0.0', + capabilities: { + major_versions: [3], + supported_versions: ['3.0.5', '3.1.0'], + // …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.0.13/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.0.13/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 +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 +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.5', '3.1.0']` 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.0.13/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.0.13/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.0.13/reference/whats-new-in-v3). For the migration path, see [Migrate from a hand-rolled agent](/dist/docs/3.0.13/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.0.13/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.0.13/building/cross-cutting/sdk-stack) — layered architecture reference +- [Where to start](/dist/docs/3.0.13/building) — decision page +- [Versioning](/dist/docs/3.0.13/reference/versioning) — spec-side version rules +- [What's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3) — protocol-wide 3.0 changelog +- [Migrate from a hand-rolled agent](/dist/docs/3.0.13/building/by-layer/L4/migrate-from-hand-rolled) — when adopting a stack with mid-flight buyers on different spec versions diff --git a/dist/docs/3.0.13/building/get-test-ready.mdx b/dist/docs/3.0.13/building/get-test-ready.mdx new file mode 100644 index 0000000000..3149c129cd --- /dev/null +++ b/dist/docs/3.0.13/building/get-test-ready.mdx @@ -0,0 +1,199 @@ +--- +title: Get Test-Ready +sidebarTitle: Get Test-Ready +description: "What a sales agent operator must have in place before running storyboards — capabilities, sandbox accounts, and the compliance test controller." +"og:title": "AdCP — Get Test-Ready" +--- + +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/client@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.0.13/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.0.13/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.0.13/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.0.13/building/verification/conformance) contract: you are promising to pass every storyboard that matches what you declare. + +The example below is for a guaranteed + proposal seller. A broadcast-TV seller would claim `sales-broadcast-tv`; a creative-only agent would claim the `creative` protocol with `creative-hosting` or `creative-generative` specialisms; a signals provider would claim `signals`. The pattern is the same: declare only what you actually implement. + +```json +{ + "supported_protocols": ["media-buy", "creative"], + "specialisms": ["sales-guaranteed", "sales-proposal-mode"], + "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.0.13/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.0.13/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: + +**Implicit 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. + +**Explicit accounts (`require_operator_auth: true`).** Accounts must be pre-provisioned by a human on your side. The runner calls `list_accounts` with a sandbox filter to discover pre-existing test accounts. 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.0.13/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 a first pass and for capability discovery, but [conformance](/dist/docs/3.0.13/building/verification/conformance) treats **deterministic mode** — full lifecycle walks enabled by the controller — as the bar for specialism coverage. + +`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.0.13/building/by-layer/L3/comply-test-controller) for scenario-by-scenario parameters and response shapes, and the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for which scenarios each specialism requires. + +### Wiring the SDK scaffold + +`@adcp/client` (≥ 5.8.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 +npm install @adcp/client@^5.8.0 +``` + + +The scaffold is TypeScript/JavaScript. Python, Go, and Java sellers implement the tool directly against the [schema](https://adcontextprotocol.org/schemas/3.0.13/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 [Schemas and SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas). + + +```ts +import { createComplyController, TestControllerError } from '@adcp/client/testing'; +// `server` is your AdcpServer or MCP server instance — see `createAdcpServer` in +// `@adcp/client/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` in `'INVALID_TRANSITION' | 'NOT_FOUND' | 'FORBIDDEN' | 'INVALID_PARAMS'` from any adapter. + +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 +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/client/server` directly. + + +## Step 4 — Run the storyboard runner + +Once the three surfaces are in place, the runner takes over: + +```bash +npx @adcp/client@latest --save-auth my-agent http://localhost:3001/mcp +npx @adcp/client@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.0.13/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.0.13/building/verification/validate-your-agent)** — CLI, Addie workflows, and multi-instance verification +- **[Compliance test controller reference](/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller)** — Full scenario-by-scenario spec +- **[Sandbox mode](/dist/docs/3.0.13/media-buy/advanced-topics/sandbox)** — The two account model paths in depth +- **[Conformance](/dist/docs/3.0.13/building/verification/conformance)** — What "conformant" and "verified" mean once your runs pass diff --git a/dist/docs/3.0.13/building/grading.mdx b/dist/docs/3.0.13/building/grading.mdx new file mode 100644 index 0000000000..85ceda9dec --- /dev/null +++ b/dist/docs/3.0.13/building/grading.mdx @@ -0,0 +1,83 @@ +--- +title: Auth Graders +sidebarTitle: Auth Graders +description: "AdCP CLI graders for RFC 9421 request-signing conformance, OAuth handshake diagnosis, and Ed25519/P-256 signing key generation and verification." +"og:title": "AdCP — Auth Graders" +--- + +`@adcp/client` 5.21+ ships CLI graders for authentication conformance. They are separate from the [compliance storyboards](/dist/docs/3.0.13/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/client@latest`. If you have `@adcp/client` installed globally (`npm install -g @adcp/client`) you can drop the `npx @adcp/client@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 +npx @adcp/client@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 +npx @adcp/client@latest diagnose-auth +``` + +The `` form uses a saved alias from `~/.adcp/config.json` (set via `npx @adcp/client@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 +npx @adcp/client@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 +npx @adcp/client@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.0.13/building/verification/validate-your-agent) — storyboard-based protocol compliance testing +- [Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication) — auth model overview, bearer tokens, RFC 9421 introduction +- [Security implementation reference](/dist/docs/3.0.13/building/by-layer/L1/security#signed-requests-transport-layer) — full RFC 9421 profile, verifier checklist, key publication rules diff --git a/dist/docs/3.0.13/building/implementation/a2a-response-extraction.mdx b/dist/docs/3.0.13/building/implementation/a2a-response-extraction.mdx new file mode 100644 index 0000000000..6c031e9983 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/a2a-response-extraction.mdx @@ -0,0 +1,277 @@ +--- +title: A2A Response Extraction +description: "How to extract AdCP response data from A2A Task objects: status-based branching, last-DataPart authority, wrapper rejection, and client implementation requirements." +"og:title": "AdCP — A2A Response Extraction" +--- + +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.0.13/building/by-layer/L0/a2a-response-format). For error-specific extraction, see [Transport Error Mapping](/dist/docs/3.0.13/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 +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 +{ + "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 +// 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 +// 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.0.13/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 +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.0.13/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.0.13/building/by-layer/L0/a2a-response-format) — canonical response structure for sellers +- [Transport Error Mapping](/dist/docs/3.0.13/building/operating/transport-errors) — error extraction from MCP and A2A +- [MCP Response Extraction](/dist/docs/3.0.13/building/by-layer/L0/mcp-response-extraction) — equivalent spec for MCP +- [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) — A2A transport integration diff --git a/dist/docs/3.0.13/building/implementation/async-operations.mdx b/dist/docs/3.0.13/building/implementation/async-operations.mdx new file mode 100644 index 0000000000..6ff203456a --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/async-operations.mdx @@ -0,0 +1,347 @@ +--- +title: Async Operations +description: "AdCP async operations guide: handling synchronous, asynchronous, and interactive (input-required) task types with polling, SSE streaming, and timeout strategies." +"og:title": "AdCP — Async Operations" +--- + +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 — configure a webhook via `push_notification_config`. 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 should configure a webhook and move on. + +:::tip Webhooks for `submitted` operations +**Webhooks** are the recommended approach for `submitted` operations — they work with any transport (MCP, A2A, REST) and handle operations that outlive a single session. See [Push Notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks). + +**Polling** via `tasks/get` works as a simpler alternative or backup. See the [polling pattern](#polling-for-submitted-operations) below. + +**MCP Tasks** handle async at the protocol level, but client support is still limited — most chat-based MCP clients (Claude Desktop, Cursor) don't yet support task-augmented tool calls. If you're building your own MCP client or using the JS SDK directly, MCP Tasks work well. See [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide#async-operations-via-mcp-tasks). +::: + +## 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 | +| `sync_creatives` | Asset review and transcoding pipelines | +| `build_creative` (with review) | Human creative review before finalizing | +| `activate_signal` | Platform deployment pipelines | + +These operations integrate with external systems or require human approval. + +## Timeout Configuration + +Set reasonable timeouts based on status: + +```javascript +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 webhook delivery window — if you're also polling as backup, use a 30-second interval. + +## 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.0.13/governance/embedded-human-judgment) framework — human judgment is embedded in system design, not bolted on afterward. + +### Approval Patterns + +```javascript +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 +{ + "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 +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 +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 +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 — need webhook or polling + if (response.status === 'submitted') { + // Poll as backup (webhook is preferred — see Push Notifications) + 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 a backup for `submitted` operations when webhooks aren't configured or as a fallback. Don't poll for `working` — the server delivers the result on the open connection. + +```javascript +// Polling tracks status to a terminal value. The completion payload +// (e.g. media_buy_id, packages) is delivered to the webhook configured +// on the original request via push_notification_config — see #3123 for +// the planned 3.1 typed-result projection on tasks/get. +async function pollUntilTerminal(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('tasks/get', { + task_id: taskId + }); + + 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 +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 +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('tasks/get', { + task_id: operation.task_id + }); + + // 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.0.13/building/by-layer/L3/webhooks) for push notifications instead of polling +- **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) for status handling details +- **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design) for production patterns diff --git a/dist/docs/3.0.13/building/implementation/comply-test-controller.mdx b/dist/docs/3.0.13/building/implementation/comply-test-controller.mdx new file mode 100644 index 0000000000..39c6283fb4 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/comply-test-controller.mdx @@ -0,0 +1,742 @@ +--- +title: Compliance test controller +description: "Optional sandbox tool that lets the storyboard runner walk full lifecycle state machines by triggering seller-side transitions deterministically." +"og:title": "AdCP — Compliance test controller" +--- + +# Compliance test controller + +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 that sellers expose in sandbox mode. It allows a storyboard runner to trigger seller-side state transitions deterministically, enabling end-to-end lifecycle verification. + +## 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.0.13/compliance/comply-test-controller-request.json) | [`comply-test-controller-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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_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" + ], + "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_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_task_completion: {task_id, result}. force_session_status: {session_id, status, termination_reason?}. simulate_delivery: {media_buy_id, impressions?, clicks?, reported_spend?, conversions?}. simulate_budget_spend: {account_id|media_buy_id, spend_percentage}. 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?}." + } + }, + "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.0.13/creative/specification#creative-status-lifecycle). + +**Params:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `creative_id` | string | Yes | Creative to transition | +| `status` | `processing` \| `approved` \| `rejected` \| `pending_review` \| `archived` | Yes | Target status | +| `rejection_reason` | string | When `status` = `rejected` | Reason for rejection | + +**Example:** + +```json +{ + "scenario": "force_creative_status", + "params": { + "creative_id": "cr-123", + "status": "rejected", + "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.0.13/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 +{ + "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 +{ + "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.0.13/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.0.13/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 +{ + "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 +{ + "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_task_completion` + +Resolves a previously-submitted async task to `completed` with a buyer-supplied result payload. The companion to `force_create_media_buy_arm`: that scenario drives the seller into the 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` (the canonical 3.0 delivery path for completion payloads) and via subsequent [`tasks/get`](/dist/docs/3.0.13/building/by-layer/L3/async-operations#polling-for-submitted-operations) calls reporting `status: "completed"`. A typed result projection on the polling response is tracked for 3.1 in [#3123](https://github.com/adcontextprotocol/adcp/issues/3123). + +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.0.13/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 +{ + "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 +{ + "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 +{ + "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 | + +**Example:** + +```json +{ + "scenario": "simulate_delivery", + "params": { + "media_buy_id": "mb-789", + "impressions": 10000, + "clicks": 150, + "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 +{ + "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. | + +**Example:** + +```json +{ + "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.0.13/core/pricing-option.json) schema (`pricing_model`, `currency`, `floor_price` for auction-based, `fixed_price` for fixed, etc.) | + +**Example:** + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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" }] + } + } +} +``` + +### 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`). 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 +{ + "success": true, + "previous_state": "processing", + "current_state": "approved", + "message": "Creative cr-123 transitioned from processing to approved" +} +``` + +**Failure (invalid transition):** + +```json +{ + "success": false, + "error": "INVALID_TRANSITION", + "error_detail": "Cannot transition from archived to processing — archived is terminal", + "current_state": "archived" +} +``` + +**Failure (unknown entity):** + +```json +{ + "success": false, + "error": "NOT_FOUND", + "error_detail": "Creative cr-unknown not found", + "current_state": null +} +``` + +### Simulation responses (`simulate_*`) + +**`simulate_delivery` response:** + +```json +{ + "success": true, + "simulated": { + "impressions": 10000, + "clicks": 150, + "reported_spend": { "amount": 150.00, "currency": "USD" } + }, + "cumulative": { + "impressions": 25000, + "clicks": 380, + "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 all simulation calls for this media buy, so callers can verify expected state before checking `get_media_buy_delivery`. + +**`simulate_budget_spend` response:** + +```json +{ + "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 | +| `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.0.13/compliance/comply-test-controller-response.json), distinct from the canonical seller-response [`error-code.json`](https://adcontextprotocol.org/schemas/3.0.13/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. + +## 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 +{ + "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.0.13/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.0.13/creative/specification#creative-status-lifecycle), [account lifecycle](/dist/docs/3.0.13/accounts/overview#account-status-lifecycle), [media buy lifecycle](/dist/docs/3.0.13/media-buy/specification), [SI session lifecycle](/dist/docs/3.0.13/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. diff --git a/dist/docs/3.0.13/building/implementation/error-handling.mdx b/dist/docs/3.0.13/building/implementation/error-handling.mdx new file mode 100644 index 0000000000..07d3589e0d --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/error-handling.mdx @@ -0,0 +1,578 @@ +--- +title: Error Handling +description: "AdCP error handling: protocol errors, task failures, and validation errors with standard error codes, recovery strategies, and exponential backoff retry logic." +"og:title": "AdCP — Error Handling" +--- + +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.0.13/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/client` 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.0.13/core/error.json) schema: + +```json +{ + "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.0.13/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.0.13/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 +// 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 +// 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.0.13/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.0.13/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.0.13/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, drawn from JSON Schema validator output. Each entry carries `pointer` (RFC 6901, matches Ajv's `instancePath`), `message`, `keyword` (the JSON Schema keyword that rejected — `required` / `type` / `format` / etc.), and optional `schemaPath`. Use on `VALIDATION_ERROR` and any other code where multiple fields were rejected at once. **`schemaPath` SHOULD NOT be emitted on production-facing endpoints** — it leaks which `oneOf` branch the validator selected, a probe oracle for adversarial callers crafting payloads against polymorphic unions. Sellers MAY emit in dev/sandbox modes. | +| `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. | + +## Standard Error Codes + +Standard error codes are defined in [`error-code.json`](https://adcontextprotocol.org/schemas/3.0.13/enums/error-code.json). Sellers MAY use codes not in this vocabulary for platform-specific errors. Agents MUST handle unknown codes by falling back to the `recovery` classification. + +**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.0.13/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 | See *AUTH_REQUIRED sub-cases* below | +| `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 + +`AUTH_REQUIRED` shares one wire code across two operationally distinct cases that agents MUST handle differently: + +| Sub-case | Behavior | Why | +|---|---|---| +| Credentials missing | Provide credentials and retry once. | Genuinely correctable from inside the agent loop. | +| Credentials presented but rejected (expired / revoked / 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. | + +A future minor release splits this code into `AUTH_MISSING` (correctable) and `AUTH_INVALID` (terminal). Until then, agents SHOULD branch on whether credentials were actually attached to the failing request: + +```javascript +case 'AUTH_REQUIRED': + 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; +``` + +### 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 | +| `REQUOTE_REQUIRED` | correctable | Requested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; `pricing_option` remains locked | Call `get_products` with `buying_mode: "refine"` against the existing `proposal_id`, then resubmit against the new `proposal_id` | +| `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 | +| `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 +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 + +### 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.0.13/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.0.13/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 +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 +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 +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 "AUTH_REQUIRED sub-cases" above. + if (!error.requestHadCredentials) { + await refreshCredentials(); + return retry(); + } + // Credentials were presented and rejected — terminal at app layer. + 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 +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 +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 +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 + }); + + if (['completed', 'failed', 'canceled'].includes(response.status)) { + // Status is terminal. The completion payload reaches the buyer + // through the webhook configured on the original request; if the + // webhook also failed, request a retry from the seller per their + // operational support channel. + 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 +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 +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 +{ + "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 +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.0.13/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.0.13/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 | + +## 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 the `recovery` classification +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.0.13/building/operating/transport-errors) for how errors travel over MCP and A2A +- **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) for status handling +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for webhook error handling +- **Security**: See [Security](/dist/docs/3.0.13/building/by-layer/L1/security) for authentication errors diff --git a/dist/docs/3.0.13/building/implementation/index.mdx b/dist/docs/3.0.13/building/implementation/index.mdx new file mode 100644 index 0000000000..a125b4fa19 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/index.mdx @@ -0,0 +1,102 @@ +--- +title: Implementation Patterns +sidebarTitle: Overview +description: "AdCP implementation patterns: task lifecycle, async operations, webhooks, error handling, security, and orchestrator design for production advertising systems." +"og:title": "AdCP — Implementation Patterns" +--- + +This section covers patterns and best practices for building robust, production-ready systems that integrate with AdCP. + +## Core Patterns + + + + Status values, state transitions, and polling patterns. The foundation for handling any AdCP operation. + + + Handling sync, async, and interactive operations. Timeouts, progress tracking, and completion handling. + + + Push notification architecture, reliability patterns, circuit breakers, and idempotent handlers. + + + Error categories, standard error codes, recovery strategies, and retry logic. + + + +## System Design + + + + Security considerations for AdCP integrations. Webhook verification, replay prevention, and access control. + + + State machine design, operation tracking, persistence patterns, and reconciliation. + + + +## Who This Section Is For + +This section is primarily for **orchestrator builders** - developers building systems that: + +- Manage multiple AdCP operations concurrently +- Need to survive restarts and recover state +- Handle long-running operations (hours to days) +- Require high reliability and auditability + +If you're building a simple integration that makes occasional AdCP calls, the [Integration](/dist/docs/3.0.13/building/by-layer/L0) section may be sufficient. + +## Key Design Principles + +### 1. Asynchronous First + +AdCP operations may take seconds, hours, or days. Design all operations as async: + +- Store operation state persistently +- Handle orchestrator restarts gracefully +- Implement proper timeout handling + +### 2. Status-Driven Logic + +Every response includes a `status` field. Build your logic around status values: + +```javascript +switch (response.status) { + case 'completed': return processResults(response); + case 'working': return pollForUpdates(response.task_id); + case 'input-required': return promptUser(response.message); + case 'failed': return handleError(response); +} +``` + +### 3. Webhooks + Polling + +Never rely solely on webhooks: + +- Configure webhooks for immediate notification +- Implement polling as backup +- Use circuit breakers for reliability + +### 4. Idempotent Operations + +Make all operations idempotent: + +- Check for existing operations before creating new ones +- Handle duplicate webhook deliveries gracefully +- Use event IDs for deduplication + +## Reading Order + +For comprehensive understanding, read in this order: + +1. **[Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** - Understand status values and transitions +2. **[Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations)** - Handle different operation types +3. **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** - Implement push notifications reliably +4. **[Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** - Handle failures gracefully +5. **[Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design)** - Put it all together + +## Next Steps + +- **Start with basics**: [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) +- **Building webhooks**: [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) +- **Complete architecture**: [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design) diff --git a/dist/docs/3.0.13/building/implementation/known-ambiguities.mdx b/dist/docs/3.0.13/building/implementation/known-ambiguities.mdx new file mode 100644 index 0000000000..4f6a680744 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/known-ambiguities.mdx @@ -0,0 +1,80 @@ +--- +title: Known spec ambiguities +description: "Open AdCP spec gaps that affect compliance testing — with workarounds and issue links. Entries are removed as the underlying issues close." +"og:title": "AdCP — Known spec ambiguities" +--- + +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/client` 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.0.13/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) — the storyboard narrative now explicitly directs API-key-only agents to declare `auth.api_key` in the test kit and omit PRM entirely. Optional-phase semantics make `oauth_discovery` failures non-fatal; `mechanism_required` passes via the API-key path. +- **Workaround**: if your agent has no OAuth issuer, do not serve `/.well-known/oauth-protected-resource/...`. Declare `auth.api_key` in your test-kit and let the OAuth discovery phase fail silently. + +### Idempotency missing-key probe via SDK + +- **Storyboard**: `universal/idempotency.yaml` step `missing_key/create_media_buy_missing_key`. +- **Gap**: the reference `@adcp/client` 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 — canonical example `universal/schema-validation.yaml > past_start_reject_path` + `past_start_adjust_path` (aggregation flag `past_start_handled`). +- **Gap**: a conformant agent picks one branch (e.g., rejects past `start_time`); the other branch's assertion (e.g., `field_present: media_buy_id`) 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/client` 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/client`'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/client` 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. diff --git a/dist/docs/3.0.13/building/implementation/mcp-response-extraction.mdx b/dist/docs/3.0.13/building/implementation/mcp-response-extraction.mdx new file mode 100644 index 0000000000..b09b4d7e71 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/mcp-response-extraction.mdx @@ -0,0 +1,163 @@ +--- +title: MCP Response Extraction +description: "How to extract AdCP success response data from MCP tool results: structuredContent, text fallback, and client implementation requirements." +"og:title": "AdCP — MCP Response Extraction" +--- + +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.0.13/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.0.13/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 +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 +{ + "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 +{ + "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 +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.0.13/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.0.13/building/operating/transport-errors) — error extraction from MCP and A2A +- [A2A Response Extraction](/dist/docs/3.0.13/building/by-layer/L0/a2a-response-extraction) — equivalent spec for A2A +- [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide) — MCP transport integration diff --git a/dist/docs/3.0.13/building/implementation/orchestrator-design.mdx b/dist/docs/3.0.13/building/implementation/orchestrator-design.mdx new file mode 100644 index 0000000000..63d188b709 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/orchestrator-design.mdx @@ -0,0 +1,527 @@ +--- +title: Orchestrator Design +description: "AdCP orchestrator design: state machine patterns, persistent operation tracking, async-first architecture, and reconciliation for multi-vendor campaign workflows." +"og:title": "AdCP — Orchestrator Design" +--- + +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 +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 +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 +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 +async def reconcile_with_server(self, adcp_client): + """Sync local state with server using tasks/list""" + server_tasks = await adcp_client.call('tasks/list', { + '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 +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 +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 +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('tasks/get', { + 'task_id': task_id + }) + + 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 +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 +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 +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/governance/campaign/tasks/report_plan_outcome) | No feedback loop — governance agent cannot improve future recommendations | + +See the [media buy governance workflow](/dist/docs/3.0.13/media-buy/index#governance) for the complete sequence with code examples, and the [seller integration guide](/dist/docs/3.0.13/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 +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 +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 +@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 +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.0.13/building/by-layer/L3/task-lifecycle) for status handling +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for push notifications +- **Security**: See [Security](/dist/docs/3.0.13/building/by-layer/L1/security) for multi-tenant security diff --git a/dist/docs/3.0.13/building/implementation/security.mdx b/dist/docs/3.0.13/building/implementation/security.mdx new file mode 100644 index 0000000000..e8f861718b --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/security.mdx @@ -0,0 +1,1695 @@ +--- +title: Security +description: "AdCP security guide: risk classification for financial operations, webhook HMAC verification, replay prevention, access control, and credential management for production deployments." +"og:title": "AdCP — Security" +--- + + +**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.0.13/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 its adagents.json-published key, and the buyer verifies against the seller's published JWKS. 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`. + +### 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`. 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.0.13/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`, collection-list `webhook_url`, TMP provider `endpoint`, `adagents.json` `authoritative_location`, and `reporting_bucket.setup_instructions`. + +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). +5. **Cap response size and timeouts.** Recommended: 5 MB body cap, 10 s connect, 10 s read. +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. + +Feature-specific security sections extend these rules with their own lifecycle and content-handling requirements: +- [Offline reporting buckets](/dist/docs/3.0.13/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.0.13/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.0.13/governance/property/managed-networks#security-considerations) — validator fetch semantics, change detection, relationship termination. +- [TMP provider registration](/dist/docs/3.0.13/trusted-match/specification#provider-registration-security) — dynamic registration authentication, router-to-provider auth, `/health` info-leakage rules. + +## Authentication Best Practices + +### Credential Storage + +```javascript +// 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 +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.0.13/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.0.13/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.0.13/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.0.13/reference/glossary#a) and [Agent](/dist/docs/3.0.13/reference/glossary#a). + +### The two-step pattern + +Every request carries an explicit `account` (via `account_id` for explicit-account models, or the `{brand, operator}` natural key for implicit models). 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 +// 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 +-- 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 +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 mutating AdCP task request (`create_media_buy`, `update_media_buy`, `sync_creatives`, `activate_signal`, `acquire_rights`, `creative_approval`, `update_rights`, `build_creative`, `calibrate_content`, `create_content_standards`, `update_content_standards`, `create_property_list`, `update_property_list`, `delete_property_list`, `create_collection_list`, `update_collection_list`, `delete_collection_list`, `log_event`, `provide_performance_feedback`, `report_usage`, `report_plan_outcome`, `si_initiate_session`, `si_send_message`, and the `sync_*` tasks). Sellers MUST reject any mutating request that omits it with `INVALID_REQUEST`. 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. + +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. For async tasks, the cached response is the `submitted` result containing `task_id`. **The cache entry is immutable** — even if the async task subsequently completes, fails, or is canceled, a replay within the TTL MUST return the originally-cached `submitted` response (with `replayed: true`), 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. This preserves the byte-stable cache property and keeps the idempotency layer decoupled from async task lifecycle — sellers don't need to update cache entries when task state changes. +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.0.13/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 ceiling: 60 inserts/sec per agent sustained (3,600/min), with burst allowance up to 300 inserts/sec over rolling 10-second windows.** 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 recommended rates bound per-agent residency to ~216,000 entries — the same order of magnitude as the 100,000-entry per-keyid webhook replay cap at [Webhook replay dedup sizing](#webhook-replay-dedup-sizing), and an order of magnitude below the 1,000,000-entry per-keyid request-replay cap at [Transport replay dedup](#transport-replay-dedup). The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceiling as a tunable configuration parameter — the 60/300/3,600 values are first-deployment starting points sized for a realistic high-volume launch pattern (≤10 media buys/min × 10 packages × 10 creatives, with 3–5× headroom for multi-campaign and retry patterns), not frozen defaults. Operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise the limit rather than accept silent rejection of legitimate traffic; operators with steady low-volume traffic MAY tighten below the starting values. Sellers SHOULD NOT publish their exact configured ceiling numerically in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceiling 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.0.13/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. + +#### 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. + +#### Response-level replay indicator + +The protocol envelope carries a top-level `replayed` boolean on responses to mutating requests: + +```json +{ + "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). + +#### 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 +{ + "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. + +**Agent retry vs. network retry.** Two cases that look similar but need opposite 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." + +When in doubt, ask whether the serialized request bytes are the same. If yes, reuse the key. If no, the intent changed — 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, so retries send the identical payload even if the planner would produce something slightly different on re-execution. + +**When the seller's capability declaration is missing.** A seller that omits `adcp.idempotency.replay_ttl_seconds` from `get_adcp_capabilities` is non-compliant. Client SDKs MUST fail closed on retry-sensitive operations 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. Read-only operations (`get_products`, `list_accounts`, etc.) are safe to issue against such a seller; only mutating requests require the declaration. + +**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. + +**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 implicit-accounts 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 +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.0.13/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.0.13/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. +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 +{ + "payload": "", + "signatures": [ + { "protected": "", + "signature": "" } + ] +} +``` + +The payload (JWS-flattened JSON serialization; compact form is also acceptable): + +```json +{ + "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: 15 minutes for any seller accepting execution-phase tokens. Governance agents MUST NOT declare `next_update` more than 15 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. +- Polling is optional for intent-phase tokens with ≤15 min `exp`. +- 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 = 2× 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. + +- 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/client` 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 +{ + "alg": "EdDSA", + "kid": "gov-2026-04", + "typ": "adcp-gov+jws" +} +``` + +Payload: +```json +{ + "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 +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 +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.0.13/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's `adagents.json` agent entries. 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 an `adcp_use: "webhook-signing"` key 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. Cross-purpose key reuse is forbidden: 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). +- 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.0.13/building/verification/grading). + +#### 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 operations the seller expects you to sign. 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, 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/client` (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. An operator signing both AdCP requests and webhooks needs distinct key material and must publish two entries with the same JWK shape, distinct `x`, distinct `kid`, and distinct `adcp_use`. The value is 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 +{ + "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": "webhook-signing", + "key_ops": ["verify"] + } + ] +} +``` + +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.0.13/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. + +#### Agent key publication + +Request-signing keys live at the signing agent's own `jwks_uri` in its operator's brand.json `agents[]` entry (or the `adagents.json` equivalent for seller-side agents publishing keys for webhook callbacks). Every agent that signs — of any `type` — uses the same publication pattern. + +**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.0.13/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. Publishers hold the corresponding private key locally with `["sign"]` per JWK spec. | +| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile), `"webhook-signing"` (seller→buyer webhook callbacks), and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. Sellers that also sign webhooks publish a separate `"webhook-signing"` key under their `adagents.json` entry — see [Webhook callbacks](#webhook-callbacks). | +| `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`: 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-signing and webhook-signing 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, webhook-signing, TMP keys + +Operators SHOULD go further and serve each signing purpose from a distinct subdomain (up to four origins). 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. 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 transport-signing and 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 has, at onboarding or via prior discovery, a mapping from the signing agent's URL to its brand.json `agents[]` entry. +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. + +#### 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. + +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 (e.g., a `buyer_id` or `agent_id` in the payload). The signature → JWKS → agent entry chain is the only authoritative identity path. + +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). On `kid` miss, 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 15 min). The fetch-failure safe-default applies: 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 +{ + "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` 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, 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`, or `media_buy.content_standards.supports_webhook_delivery: 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 +{ + "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`. `false` is the recommended posture in 3.x. + +The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notification_config.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` 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 `push_notification_config` registration. + +**Key publication.** Webhook-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. An agent that signs both outgoing requests and outgoing webhooks publishes one JWKS with two distinct JWKs differentiated by `adcp_use`. Each webhook-signing JWK MUST declare: + +| Member | Value | +|---|---| +| `use` | `"sig"` | +| `key_ops` | `["verify"]` | +| `adcp_use` | `"webhook-signing"` | +| `kid` | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` | +| `alg` | `"EdDSA"` or `"ES256"` | + +Cross-purpose reuse is forbidden and locally enforceable: a request-signing key MUST NOT verify a webhook signature, and a webhook-signing key MUST NOT verify a request signature. Buyers verifying a webhook MUST reject any JWK whose `adcp_use` is not exactly `"webhook-signing"` with `webhook_signature_key_purpose_invalid`. + +**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 across `adcp_use` values within the same JWKS are forbidden specifically so a request-signing-key compromise cannot be repurposed as a webhook-signing capability. + +**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 15 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` 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`, 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` equals `"webhook-signing"`. Reject on any mismatch, including absent `adcp_use` (`webhook_signature_key_purpose_invalid`). +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.0.13/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.0.13/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` ≠ `webhook-signing` | `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`; 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` 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, four signing systems (governance JWS `adcp_use: "governance-signing"`, request-signing 9421 `adcp_use: "request-signing"`, webhook-signing 9421 `adcp_use: "webhook-signing"`, TMP envelope with its own future `adcp_use` value), each `kid`-scoped. 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](../../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 +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.adcp_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 +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 +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" — everywhere else, zero. +- 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.0.13/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 +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 +// 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 +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.0.13/building/concepts/security-model) for the threat model and the five-layer defense narrative this reference implements +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for webhook security patterns +- **Error Handling**: See [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) for authentication errors +- **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design) for multi-tenant security diff --git a/dist/docs/3.0.13/building/implementation/seller-integration.mdx b/dist/docs/3.0.13/building/implementation/seller-integration.mdx new file mode 100644 index 0000000000..8dc746c773 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/seller-integration.mdx @@ -0,0 +1,327 @@ +--- +title: Making your inventory available to AI agents +sidebarTitle: Seller integration +description: "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." +"og:title": "AdCP — Making your inventory available to AI agents" +--- + +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 three parts: + + + +### Make your agent discoverable + +Publish an `adagents.json` file at your domain root. This file declares your properties and the agents authorized to sell your inventory — similar to how `ads.txt` works for supply chain transparency. + +```json +{ + "version": "1.0", + "properties": [ + { + "domain": "publisher.example.com", + "agents": [ + { + "agent_url": "https://ads.publisher.example.com", + "relationship": "direct", + "supported_protocols": ["media_buy", "creative"] + } + ] + } + ] +} +``` + +Buyer agents check `adagents.json` to find authorized sales agents, verify relationships, and discover which protocol domains you support. + + +This shows a simplified structure. The full adagents.json schema uses separate `properties` and `authorized_agents` arrays with a `delegation_type` field. See the [adagents.json tech spec](/dist/docs/3.0.13/governance/property/adagents) for the complete schema. + + +### 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 +{ + "buying_mode": "brief", + "brief": "Premium display placements for consumer electronics brand" +} +``` + +Your response includes structured product objects with pricing, formats, and delivery types: + +```json +{ + "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 +{ + "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": [ + { "placement_id": "pre_roll", "name": "Pre-roll (30s)" }, + { "placement_id": "host_read", "name": "Mid-roll host read (60s)" } + ], + "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.0.13/media-buy/product-discovery/collections-and-installments) for the full content model and [Media products](/dist/docs/3.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.0.13/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 explicit accounts — set `require_operator_auth: true` in [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities) so each operator authenticates independently. +- **Open platforms** (publishers, SSPs) can use implicit accounts — the agent is trusted and declares accounts via `sync_accounts`. + +See [Accounts and Agents](/dist/docs/3.0.13/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 provision test accounts and validate the full integration — product discovery, media buy creation, delivery reporting — before committing real spend. Without sandbox, buyers must test against live inventory, which slows adoption and increases onboarding friction. See [Sandbox mode](/dist/docs/3.0.13/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.0.13/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.0.13/protocol/required-tasks) for the full list of required seller tasks. + +## 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.0.13/media-buy/product-discovery/collections-and-installments). For exclusivity and sponsorship patterns, see [Media products](/dist/docs/3.0.13/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.0.13/governance/property/specification) for the full schema and the [adagents.json tech spec](/dist/docs/3.0.13/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.0.13/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.0.13/accounts/tasks/sync_governance)), the seller MUST call [`check_governance`](/dist/docs/3.0.13/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 +// 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.0.13/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.0.13/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.0.13/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.0.13/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. + + diff --git a/dist/docs/3.0.13/building/implementation/storyboard-troubleshooting.mdx b/dist/docs/3.0.13/building/implementation/storyboard-troubleshooting.mdx new file mode 100644 index 0000000000..3eb6ee14da --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/storyboard-troubleshooting.mdx @@ -0,0 +1,94 @@ +--- +title: Storyboard troubleshooting +description: "Common failure patterns when running AdCP compliance storyboards — missing fixtures, signature challenges, envelope drift, context echo, capability mismatches, and state-machine error codes." +"og:title": "AdCP — Storyboard troubleshooting" +--- + +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:** Implement `comply_test_controller` and honor the seed scenarios declared in the storyboard's `fixtures:` block. 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. + +See [Compliance test controller — Scenarios](/dist/docs/3.0.13/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/client/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.0.13/building/by-layer/L1/security#signed-requests-transport-layer) for the challenge-header format and the [transport-error binding order](/dist/docs/3.0.13/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.0.13/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.0.13/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` 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` in the test kit so the runner uses Bearer auth, 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. + +## `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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/building/implementation/task-lifecycle.mdx b/dist/docs/3.0.13/building/implementation/task-lifecycle.mdx new file mode 100644 index 0000000000..0fb2830925 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/task-lifecycle.mdx @@ -0,0 +1,269 @@ +--- +title: Task Lifecycle +description: "AdCP task lifecycle: status values (submitted, working, input-required, completed, failed), state transitions, response structure, and polling patterns for all operations." +"og:title": "AdCP — Task Lifecycle" +--- + +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 Transport-specific task management +The status values and lifecycle described here are transport-independent — they apply regardless of how you access AdCP. The *mechanism* for tracking async tasks varies by transport: +- **MCP**: Use [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) — the client polls via `tasks/get` and retrieves results via `tasks/result` at the protocol level. See [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide#async-operations-via-mcp-tasks). +- **A2A**: Use native A2A task lifecycle with SSE streaming. See [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide). +- **REST**: Use AdCP's `task_id` with polling or [push notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks). +::: + +## 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 +{ + "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 +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 +{ + "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 +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.0.13/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 +{ + "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 +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 +{ + "status": "submitted", + "message": "Media buy submitted for publisher approval", + "context_id": "ctx-123", + "task_id": "task-456" +} +``` + +**Transport-specific handling for `submitted` operations:** +- **MCP**: Use [MCP Tasks](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide#async-operations-via-mcp-tasks) or poll via `tasks/get` +- **A2A**: Subscribe to SSE stream for real-time updates +- **REST**: Use [push notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks) (recommended) or poll with `task_id` + +## 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). + +:::note Completion payload retrieval in 3.0 +The 3.0 `tasks/get` schema returns task status, timing, history, and (optionally) progress and error details. It does not specify a typed field for the terminal payload of a completed task — for `create_media_buy` and similar operations, the `media_buy_id`, `packages`, and other task-specific fields are delivered to the buyer's webhook URL configured via `push_notification_config` on the original request. Buyers that need the completion payload MUST configure a webhook in 3.0; polling alone reports terminal status. A typed `include_result` request flag and response projection are tracked for 3.1 in [#3123](https://github.com/adcontextprotocol/adcp/issues/3123). +::: + +```javascript +// Polling tracks status; the completion payload arrives via webhook. +async function pollUntilTerminal(taskId, pollInterval = 30_000) { + while (true) { + await sleep(pollInterval); + + const response = await adcp.call('tasks/get', { + task_id: taskId + }); + + if (['completed', 'failed', 'canceled'].includes(response.status)) { + return response; + } + } +} +``` + +### Timeout Configuration + +```javascript +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 `tasks/list` to recover from lost state: + +```javascript +// Find all pending operations +const pending = await session.call('tasks/list', { + 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.0.13/building/by-layer/L3/async-operations) for handling different operation types +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for push notification patterns +- **Error Handling**: See [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) for error categories and recovery diff --git a/dist/docs/3.0.13/building/implementation/transport-errors.mdx b/dist/docs/3.0.13/building/implementation/transport-errors.mdx new file mode 100644 index 0000000000..b31e62451d --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/transport-errors.mdx @@ -0,0 +1,696 @@ +--- +title: Transport Error Mapping +description: "How AdCP structured errors travel over MCP and A2A transports: extraction paths, JSON-RPC codes, recovery behavior, and client implementation requirements." +"og:title": "AdCP — Transport Error Mapping" +--- + +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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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_REQUIRED` | 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 +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 +{ + "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.0.13/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 +{ + "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.0.13/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/client` | +| **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.0.13/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 +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 +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.0.13/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 +// 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_REQUIRED: 'correctable', + POLICY_VIOLATION: 'correctable', + PRODUCT_NOT_FOUND: 'correctable', + PRODUCT_UNAVAILABLE: 'correctable', + PROPOSAL_EXPIRED: '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', +}; + +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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/error-details/rate-limited.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/rate-limited.json) +- [`/schemas/3.0.13/error-details/budget-too-low.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/budget-too-low.json) +- [`/schemas/3.0.13/error-details/audience-too-small.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/audience-too-small.json) +- [`/schemas/3.0.13/error-details/account-setup-required.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/account-setup-required.json) +- [`/schemas/3.0.13/error-details/creative-rejected.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/creative-rejected.json) +- [`/schemas/3.0.13/error-details/policy-violation.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/policy-violation.json) +- [`/schemas/3.0.13/error-details/conflict.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/error-details/vendor-error-codes.json) by submitting a PR + +```javascript +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/client`) 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 +// 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.0.13/building/by-layer/L3/error-handling) — error schema, standard codes, recovery strategies +- [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide) — MCP transport integration +- [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) — A2A transport integration +- [A2A Response Format](/dist/docs/3.0.13/building/by-layer/L0/a2a-response-format) — canonical A2A response structure diff --git a/dist/docs/3.0.13/building/implementation/webhook-verifier-tuning.mdx b/dist/docs/3.0.13/building/implementation/webhook-verifier-tuning.mdx new file mode 100644 index 0000000000..752132bfa2 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/webhook-verifier-tuning.mdx @@ -0,0 +1,149 @@ +--- +title: Webhook Verifier Tuning Guide +sidebarTitle: Webhook Verifier Tuning +description: "Non-normative tuning recipes for webhook verifier thresholds — starting values, baselining methodology, and attack-scenario walkthroughs." +"og:title": "AdCP — Webhook Verifier Tuning Guide" +--- + + +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.0.13/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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/building/implementation/webhooks.mdx b/dist/docs/3.0.13/building/implementation/webhooks.mdx new file mode 100644 index 0000000000..04b40d1eb4 --- /dev/null +++ b/dist/docs/3.0.13/building/implementation/webhooks.mdx @@ -0,0 +1,495 @@ +--- +title: Push Notifications +description: "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." +"og:title": "AdCP — Push Notifications" +--- + +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 +2. A webhook URL is built by substituting that ID (and other routing params) into a URL template +3. `push_notification_config` is injected into the task request body with just the URL — no secret required +4. The seller POSTs webhook notifications to your URL as the task status changes, signing each POST with its `adcp_use: "webhook-signing"` key published in its own brand.json `agents[]` entry +5. You verify the signature against the seller's published JWKS and dedupe by `idempotency_key` +6. Each notification echoes `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/cd51e063-2b79-4a6d-afac-ed7789c3a443" + // 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/cd51e063-2b79-4a6d-afac-ed7789c3a443 + 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": "cd51e063-2b79-4a6d-afac-ed7789c3a443", ← echoed from your URL + "status": "completed", + "result": { ... } + } +``` + +If you're using the `@adcp/client` 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 webhook-signing JWK at your brand.json `agents[]` entry (with `adcp_use: "webhook-signing"`) and the client signs outgoing webhooks for you. + +:::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.0.13/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: ... } }` | +| **A2A configuration object** | `pushNotificationConfig` | `configuration: { pushNotificationConfig: { url: ... } }` | + +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 +{ + "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/op_abc123" + } +} +``` + +`authentication` is omitted in the default case — the seller signs with its own `adcp_use: "webhook-signing"` key. 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 +{ + "message": { + "parts": [{ + "kind": "data", + "data": { + "skill": "create_media_buy", + "parameters": { + "packages": [...] + } + } + }] + }, + "configuration": { + "pushNotificationConfig": { + "url": "https://you.com/webhooks/adcp/create_media_buy/op_abc123" + } + } +} +``` + +## Operation IDs and URL templates + +Operation IDs let you route incoming webhooks to the right handler. The typical pattern: + +1. Generate a unique ID per task call +2. Embed it in the webhook URL path +3. The seller echoes `operation_id` in the payload — no URL parsing needed + +**URL template pattern:** +``` +https://you.com/webhooks/{task_type}/{agent_id}/{operation_id} +``` + +**Example (client library handles this automatically):** +```typescript +import { randomUUID } from 'crypto'; + +const operationId = randomUUID(); // e.g. "cd51e063-2b79-4a6d-afac-ed7789c3a443" +const webhookUrl = `https://you.com/adcp/webhook/create_media_buy/${agentId}/${operationId}`; + +// pass webhookUrl in push_notification_config.url +``` + +The seller's webhook payload will include `"operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443"`, so your handler can route to the right pending operation without parsing 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.0.13/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 `completed` or `failed`), no webhook is sent — you already have the result. + +**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 +{ + "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. + +### 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 +{ + "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 | + +### 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.0.13/building/by-layer/L1/security#webhook-callbacks). The seller signs with its `adcp_use: "webhook-signing"` key published in its own brand.json `agents[]` entry; 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.0.13/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.0.13/building/by-layer/L1/security#webhook-error-taxonomy)). +- `tag` MUST be `adcp/webhook-signing/v1`. +- Resolve `keyid` via the seller's `adagents.json` `agents[]` entry (you already have the seller's agent URL from your integration). + +**Receiver implementation sketch:** +```typescript +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's adagents.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 +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.0.13/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 +{ + "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.0.13/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 +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 +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.0.13/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 +{ + "authentication": { + "schemes": ["Bearer"], + "credentials": "your_bearer_token_min_32_chars" + } +} +``` + +```javascript +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.0.13/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 +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. + +## 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 routing, not URL parsing +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.0.13/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 +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.0.13/media-buy/task-reference) for details on `reporting_webhook`. + +## Next steps + +- [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) — status values and transitions +- [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) — handling long-running tasks +- [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) — webhook error patterns diff --git a/dist/docs/3.0.13/building/index.mdx b/dist/docs/3.0.13/building/index.mdx new file mode 100644 index 0000000000..1c650736a0 --- /dev/null +++ b/dist/docs/3.0.13/building/index.mdx @@ -0,0 +1,73 @@ +--- +title: Building with AdCP +sidebarTitle: Overview +description: "Build with AdCP: integration guides for MCP and A2A protocols, authentication, async operations, error handling, and orchestrator design patterns." +"og:title": "AdCP — Building with AdCP" +--- + +{/* Remove after 2026-04-25 */} + +**`adcp comply` is now `npx @adcp/client@latest storyboard run`.** Running without a storyboard ID discovers your agent's tools and runs all matching storyboards — same behavior, one less concept. See [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) for the updated CLI reference. + + +This section provides everything you need to understand, integrate, and build robust systems with AdCP. + +## Learning Path + + + + Why AdCP exists, the problems it solves, and protocol comparison. Start here if you're new. + + + Technical building blocks: MCP or A2A protocols, capability discovery, authentication, and data models. + + + Build production-ready systems with async operations, webhooks, error handling, and orchestrator patterns. + + + +## Quick Start + +**Want a coding agent to build it for you?** + +- [Build an Agent](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) - Point a coding agent at a skill file, get a storyboard-compliant agent in minutes + +**Already know which protocol you're using?** + +- [MCP Integration Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide) - For Claude, AI assistants, and MCP-compatible tools +- [A2A Integration Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) - For Google AI agents and A2A-compatible workflows + +**Need to choose a protocol?** + +See [Protocol Comparison](/dist/docs/3.0.13/building/concepts/protocol-comparison) for a detailed comparison. + +## Section Overview + +### Understanding AdCP + +Conceptual foundation for everyone working with AdCP: + +- **[Why AdCP](/dist/docs/3.0.13/building/concepts)** - The strategic vision: unifying buying paradigms and enabling AI surfaces +- **[Protocol Comparison](/dist/docs/3.0.13/building/concepts/protocol-comparison)** - MCP vs A2A at a glance + +### Foundations + +Technical building blocks for any AdCP implementation: + +- **[MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide)** - Tool calls, context, and examples +- **[A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide)** - Tasks, streaming, and artifacts +- **[Capability Discovery](/dist/docs/3.0.13/protocol/get_adcp_capabilities)** - Discover what an agent supports +- **[Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication)** - Credentials and permissions +- **[Context & Sessions](/dist/docs/3.0.13/building/by-layer/L2/context-sessions)** - Managing state across requests +- **[Schemas and SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas)** - Access schemas and official client libraries + +### Implementation Patterns + +For building robust, production-ready systems: + +- **[Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** - Status values, transitions, and polling +- **[Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations)** - Handling sync, async, and interactive tasks +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** - Push notifications and reliability patterns +- **[Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** - Error categories, codes, and recovery +- **[Security](/dist/docs/3.0.13/building/by-layer/L1/security)** - Security considerations and best practices +- **[Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design)** - State machines and system architecture diff --git a/dist/docs/3.0.13/building/integration/a2a-guide.mdx b/dist/docs/3.0.13/building/integration/a2a-guide.mdx new file mode 100644 index 0000000000..b4ae557d01 --- /dev/null +++ b/dist/docs/3.0.13/building/integration/a2a-guide.mdx @@ -0,0 +1,1019 @@ +--- +title: A2A Guide +description: "AdCP A2A integration guide: client setup, agent card verification, SSE streaming for async tasks, artifact handling, and response format for Agent-to-Agent Protocol." +"og:title": "AdCP — A2A Guide" +--- + + +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.0.13/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/client`) 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.0.13/building/by-layer/L0/a2a-response-extraction)) accept both shapes during the compatibility period. + +## A2A Client Setup + +### 1. Initialize A2A Client + +```javascript +const a2a = new A2AClient({ + endpoint: 'https://adcp.example.com/a2a', + apiKey: process.env.ADCP_API_KEY, + agent: { + name: "AdCP Media Buyer", + version: "1.0.0" + } +}); +``` + +### 2. Verify Agent Card + +```javascript +// 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 +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 +// 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 +// 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 +// 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 +// 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.0.13/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 +{ + "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.0.13/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.0.13/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 +// 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.0.13/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. + +### Best Practice: URL-Based Routing + +**Recommended:** Encode routing information (`task_type`, `operation_id`) in the webhook URL, not the payload. + +**Why this approach?** +- ✅ **Industry standard pattern** - Widely adopted for webhook routing across major APIs +- ✅ **Separation of concerns** - URLs handle routing, payloads contain data +- ✅ **Protocol-agnostic** - Same pattern works for MCP, A2A, REST, future protocols +- ✅ **Cleaner handlers** - Route with URL framework, not payload parsing + +**URL Pattern Options:** + +```javascript +// Option 1: Path parameters (recommended) +url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}` +// Example: /webhooks/a2a/create_media_buy/op_nike_q1_2025 + +// Option 2: Query parameters +url: `https://buyer.com/webhooks/a2a?task=${taskType}&op=${operationId}` + +// Option 3: Subdomain routing +url: `https://${taskType}.webhooks.buyer.com/${operationId}` +``` + +**Example Configuration:** + +```javascript +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}`, + 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.0.13/building/by-layer/L3/webhooks). + +## SSE Streaming (A2A-Specific) + +A2A's key advantage is real-time updates via Server-Sent Events: + +### Task Monitoring + +```javascript +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 +// 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 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; + 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 +{ + "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 +{ + "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.0.13/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.0.13/core/async-response-data.json) + +### Webhook Handler Example + +```javascript +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 +// 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 +// 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 +// 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 +// 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.0.13/building/by-layer/L3/webhooks). + +### Skill Structure +```javascript +// 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.0.13/media-buy) and [Signals](/dist/docs/3.0.13/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 +// 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 +{ + "name": "AdCP Media Buy Agent", + "description": "AI-powered media buying agent", + "version": "1.0.0", + "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 +{ + "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.0.13/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 +// 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 version negotiation, use [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities#version-negotiation) with the `adcp_major_version` field. +::: + +**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 +// 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.0.13/building/operating/transport-errors). + +```javascript +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.0.13/creative/task-reference/sync_creatives) for complete testable examples. + +The `@adcp/client` 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.0.13/building/by-layer/L3/task-lifecycle) for status handling and workflows +- **Task Reference**: See [Media Buy Tasks](/dist/docs/3.0.13/media-buy) and [Signals](/dist/docs/3.0.13/signals/overview) +- **Protocol Comparison**: Compare with [MCP integration](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/task-lifecycle) - this guide focuses on A2A transport specifics only.** \ No newline at end of file diff --git a/dist/docs/3.0.13/building/integration/a2a-response-format.mdx b/dist/docs/3.0.13/building/integration/a2a-response-format.mdx new file mode 100644 index 0000000000..e6211d892f --- /dev/null +++ b/dist/docs/3.0.13/building/integration/a2a-response-format.mdx @@ -0,0 +1,822 @@ +--- +title: A2A Response Format +description: "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." +"og:title": "AdCP — A2A Response Format" +--- + + +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.0.13/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/client`) 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 +{ + "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 +{ + "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.0.13/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 +// ❌ 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.0.13/building/by-layer/L0/a2a-response-extraction#extraction-algorithm). + +```javascript +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 +// 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 +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 +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 +// 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 +// 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 +// 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 +{ + "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 +{ + "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 +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.0.13/building/by-layer/L3/webhooks). + +## File Parts in Responses + +Creative operations MAY include file references: + +```json +{ + "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 +// 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/building/by-layer/L0/a2a-guide) - Complete A2A integration guide +- [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) - Status handling patterns +- [Error Handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling) - Fatal vs non-fatal errors +- [Protocol Comparison](/dist/docs/3.0.13/building/concepts/protocol-comparison) - MCP vs A2A differences diff --git a/dist/docs/3.0.13/building/integration/account-state.mdx b/dist/docs/3.0.13/building/integration/account-state.mdx new file mode 100644 index 0000000000..edf19ae9c9 --- /dev/null +++ b/dist/docs/3.0.13/building/integration/account-state.mdx @@ -0,0 +1,249 @@ +--- +title: Account state +description: "AdCP account state model: how accounts hold catalogs, creatives, audiences, event sources, and campaigns. Sync tasks, upsert semantics, and async approval workflows." +"og:title": "AdCP — Account state" +--- + +# 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 +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 +{ + "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 +{ + "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 +{ + "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.0.13/accounts/tasks/sync_governance) registers governance agents on the account. Once configured, sellers with governance support will call [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance) before confirming media buys. + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +// 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.0.13/building/by-layer/L2/accounts-and-agents)** — Account identity, billing models, and `sync_accounts` details +- **[Async operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations)** — How async approval workflows work +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** — Receiving notifications when async operations complete +- **[Catalogs](/dist/docs/3.0.13/creative/catalogs)** — Typed data feeds that provide the items publishers render in ads diff --git a/dist/docs/3.0.13/building/integration/accounts-and-agents.mdx b/dist/docs/3.0.13/building/integration/accounts-and-agents.mdx new file mode 100644 index 0000000000..732ac3056b --- /dev/null +++ b/dist/docs/3.0.13/building/integration/accounts-and-agents.mdx @@ -0,0 +1,501 @@ +--- +title: Accounts and agents +description: "AdCP accounts and agents: the four entities in every transaction (brand, account, operator, agent), explicit vs implicit account models, and billing configuration." +"og:title": "AdCP — Accounts and agents" +--- + +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.0.13/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` (explicit accounts) or a natural key (`brand`, `operator`) (implicit accounts). Sandbox accounts follow the same model — explicit sandboxes use `account_id`, implicit 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.0.13/accounts/overview) for the full commercial model and [sync_accounts](/dist/docs/3.0.13/accounts/tasks/sync_accounts) for the task reference. + +## What sellers declare + +Sellers configure the `account` section of [`get_adcp_capabilities`](/dist/docs/3.0.13/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 single field determines both the authentication model and how accounts are referenced: + +When `false` (default) — **implicit 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` — **explicit accounts**: 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. The buyer discovers accounts via `list_accounts` and passes a seller-assigned `account_id`. + +For sandbox, the path follows the account model: explicit accounts (`require_operator_auth: true`) discover pre-existing test accounts via `list_accounts`; implicit accounts declare sandbox via `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.0.13/accounts/tasks/get_account_financials). This only applies to operator-billed accounts. + +**Example capabilities:** + +```json +{ + "account": { + "require_operator_auth": false, + "supported_billing": ["operator", "agent"] + } +} +``` + +Sellers that support `advertiser` billing declare it explicitly: + +```json +{ + "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 | `require_operator_auth` | `supported_billing` | +|---------------|------------------------|-------------------| +| [Social / walled garden](#social-platform) | `true` | `["operator"]` | +| [Direct publisher](#direct-publisher) | `false` | `["operator"]` or `["operator", "agent"]` | +| [DSP / programmatic](#dsp--programmatic) | `false` | `["agent"]` | + +### Social platform + +The operator already has an account on the platform — an ad account, a business manager, a self-serve dashboard. The agent obtains the operator's credentials (via OAuth or API key) and opens a per-operator session. The platform bills the operator directly. + +**Capabilities:** + +```json +{ + "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 `sync_accounts` to set up each brand for this operator +3. Wait for account status `active` (poll `list_accounts` if `pending_approval`) +4. Call `get_products` / `create_media_buy` with the operator's session and `account` reference + +**sync_accounts request:** + +```json +{ + "accounts": [{ + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com", + "billing": "operator" + }] +} +``` + +Seller checks `nova-brands.com/.well-known/brand.json`, finds Pinnacle Media in `authorized_operators`, and fast-tracks provisioning: + +```json +{ + "accounts": [{ + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com", + "action": "created", + "status": "active", + "billing": "operator", + "account_scope": "operator_brand" + }] +} +``` + +**Key point:** The operator's credential — not the agent's — authorizes all calls in that session. Brand.json verification is secondary to the credential. + +### 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 +{ + "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 +{ + "accounts": [{ + "brand": { "domain": "acme-corp.com" }, + "operator": "acme-corp.com", + "billing": "operator" + }] +} +``` + +Seller acknowledges the request but requires setup before provisioning: + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "accounts": [{ + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com", + "billing": "agent" + }] +} +``` + +Account active immediately: + +```json +{ + "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 +{ + "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. + +## 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 which model applies — and the model determines the buyer's entire integration path. + +### Explicit accounts (`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 agent discovers the account via `list_accounts`. The agent is not involved in authentication or billing — those are handled between the advertiser and seller directly. + +**Typical sellers:** Social platforms, self-serve ad platforms — anywhere the advertiser already has an account. + +**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.) + +The agent doesn't set up accounts, negotiate billing, or manage authentication with the seller. It just discovers what already exists and lets the human choose. + +### Implicit 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. + +**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 implicit accounts; explicit sandbox accounts are pre-existing. | + +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: + - **Implicit accounts** (`require_operator_auth: false`): pass the natural key `{ "brand": { "domain": "acme-corp.com" }, "operator": "pinnacle-media.com" }` + - **Explicit accounts** (`require_operator_auth: true`): pass `{ "account_id": "acc_acme_001" }` (discover via `list_accounts`) + - **Sandbox (implicit)**: pass the natural key with `sandbox: true` (declared via `sync_accounts`) + - **Sandbox (explicit)**: pass `{ "account_id": "test_acc_001" }` (pre-existing test account, discovered via `list_accounts`) +4. When anything changes (billing model, new brand, new operator), call `sync_accounts` again + +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.0.13/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` | The agent's default account | Agent sends any brand — seller routes to the standing agent 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. + +`sync_accounts` does not return `account_id` — the seller manages account identifiers internally. For explicit accounts (`require_operator_auth: true`), discover account IDs via `list_accounts` — including sandbox test accounts. For implicit accounts (`require_operator_auth: false`), use natural keys (`brand` + `operator`) on subsequent requests — adding `sandbox: true` for sandbox 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 +{ + "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 +{ + "house": { + "domain": "local-bakery.com" + }, + "authoritative_location": "https://registry.agenticadvertising.org/brands/local-bakery.com" +} +``` diff --git a/dist/docs/3.0.13/building/integration/authentication.mdx b/dist/docs/3.0.13/building/integration/authentication.mdx new file mode 100644 index 0000000000..0726a047ad --- /dev/null +++ b/dist/docs/3.0.13/building/integration/authentication.mdx @@ -0,0 +1,316 @@ +--- +title: Authentication +description: "AdCP authentication guide: public vs authenticated operations, bearer token implementation, and credential management for buyer and seller agents." +"og:title": "AdCP — Authentication" +--- + + +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.0.13/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. | +| **Bearer tokens** | Permitted; effective baseline for 3.0 | **PROHIBITED for mutating / financial operations**; permitted for read / discovery only | Documented sunset for mutating ops — see the [known limitation](/dist/docs/3.0.13/reference/known-limitations). | + + + **3.0 mutating-operation floor.** Until 3.1 lands, Bearer tokens 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. + + +### Bearer tokens (3.0 baseline) + +``` +Authorization: Bearer +``` + +Tokens may be: +- **Opaque tokens**: Server-validated strings mapped to agents +- **JWT tokens**: Self-contained tokens with embedded claims + +Implementations MUST enforce TLS 1.2+ on all Bearer-authenticated endpoints. See the [implementation security reference](/dist/docs/3.0.13/building/by-layer/L1/security) for transport requirements. + +### 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.0.13/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 +{ + "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.0.13/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.0.13/core/account.json). + +## Tenant resolution + +AdCP resolves tenant from the authenticated principal, not from request payloads. Seller agents map the authenticated identity (bearer token, 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.0.13/contributing/storyboard-authoring). Production sellers do not require it. + +## Protocol Configuration + +Both MCP and A2A protocols use the same authentication header. Configure your client with: + +```json +{ + "auth": { + "type": "bearer", + "token": "" + } +} +``` + +The client library handles adding the `Authorization: Bearer ` header to requests. + +## 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 +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/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 +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +async with streamablehttp_client( + "https://test-agent.adcontextprotocol.org/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 +# 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.0.13/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 +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 https://agenticadvertising.org/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.0.13/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 +{ + "error": { + "code": "AUTH_REQUIRED", + "message": "Authentication required for this operation" + } +} +``` + +### Invalid or Expired Credentials + +```json +{ + "error": { + "code": "AUTH_INVALID", + "message": "Invalid or expired credentials" + } +} +``` + +### Insufficient Permissions + +```json +{ + "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 +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +Configure your client with this token: + +```json +{ + "agent_uri": "https://test-agent.adcontextprotocol.org/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.0.13/media-buy/advanced-topics/sandbox) for testing capabilities including sandbox mode for risk-free development. \ No newline at end of file diff --git a/dist/docs/3.0.13/building/integration/context-sessions.mdx b/dist/docs/3.0.13/building/integration/context-sessions.mdx new file mode 100644 index 0000000000..fc5cf19633 --- /dev/null +++ b/dist/docs/3.0.13/building/integration/context-sessions.mdx @@ -0,0 +1,299 @@ +--- +title: Context & Sessions +description: "AdCP context_id vs task_id explained. How to manage conversation state, session continuity, and extension fields across MCP and A2A protocol requests." +"og:title": "AdCP — Context & Sessions" +--- + +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 +// 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 +// 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 +// 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 +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 +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 +{ + "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 +// ✅ 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 +{ + "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 +{ + "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 diff --git a/dist/docs/3.0.13/building/integration/index.mdx b/dist/docs/3.0.13/building/integration/index.mdx new file mode 100644 index 0000000000..0253a5d3ac --- /dev/null +++ b/dist/docs/3.0.13/building/integration/index.mdx @@ -0,0 +1,70 @@ +--- +title: Foundations +sidebarTitle: Overview +description: "AdCP integration foundations: choose between MCP and A2A protocols, set up authentication, manage context and sessions, and configure accounts and agents." +"og:title": "AdCP — Foundations" +--- + +This section covers the foundational technical concepts for building any AdCP implementation - whether you're building a client, a server, or an orchestrator. + +## Choose Your Protocol + + + + For Claude, AI assistants, and MCP-compatible tools. Tool-based, request/response pattern. + + + For Google AI agents and A2A workflows. Task-based, message/artifact pattern with SSE streaming. + + + +Not sure which to choose? See [Protocol Comparison](/dist/docs/3.0.13/building/concepts/protocol-comparison). + +## Core Concepts + + + + How to authenticate with AdCP agents. Bearer tokens, agents, and accounts. + + + Managing conversation state with context_id vs task_id. Extension fields and opaque context. + + + Access schemas and official client libraries for JavaScript and Python. + + + +## Getting Started + +### 1. Choose a Protocol +- **MCP** for tool-based integrations (Claude, MCP-compatible systems) +- **A2A** for task-based integrations (Google AI, agent-to-agent workflows) + +### 2. Understand Authentication +- How agents and accounts are identified +- Bearer token patterns +- See [Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication) + +### 3. Handle Context +- `context_id` for conversation continuity +- `task_id` for tracking async operations +- See [Context & Sessions](/dist/docs/3.0.13/building/by-layer/L2/context-sessions) + +## What Both Protocols Provide + +Regardless of protocol choice, you get: + +| Feature | Support | +|---------|---------| +| All AdCP tasks | Same tasks, same capabilities | +| Unified status system | `completed`, `working`, `input-required`, `failed`, etc. | +| Context management | Session continuity across requests | +| Async operations | Long-running tasks with webhooks or polling | +| Human-in-the-loop | Approval workflows for sensitive operations | +| Error handling | Consistent error codes and recovery patterns | + +## Next Steps + +- **Ready to connect?** Start with [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide) or [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) +- **Need to authenticate?** See [Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication) +- **Building production systems?** Continue to [Implementation Patterns](/dist/docs/3.0.13/building/by-layer/L3) diff --git a/dist/docs/3.0.13/building/integration/mcp-guide.mdx b/dist/docs/3.0.13/building/integration/mcp-guide.mdx new file mode 100644 index 0000000000..f01982b700 --- /dev/null +++ b/dist/docs/3.0.13/building/integration/mcp-guide.mdx @@ -0,0 +1,866 @@ +--- +title: MCP Guide +description: "AdCP MCP integration guide: tool call patterns, context_id management, response parsing, and wire format for Model Context Protocol implementations." +"og:title": "AdCP — MCP Guide" +--- + + +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.0.13/building/by-layer/L3/task-lifecycle). + +## Testing AdCP via MCP + +You can test AdCP tasks using the [CLI tools](/dist/docs/3.0.13/building/by-layer/L0/schemas#cli-tools) or by chatting with [Addie](https://agenticadvertising.org), the AgenticAdvertising.org assistant. + +## Tool Call Patterns + +### Basic Tool Invocation + +```javascript +// 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 +// 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 +// 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 + +**New in AdCP 1.6.0**: All responses include unified status field. + +MCP responses use a **flat structure** where task-specific fields are at the top level alongside protocol fields: + +```json +{ + "status": "completed", // Unified status (see Core Concepts) + "message": "Found 5 products", // Human-readable summary + "context_id": "ctx-abc123", // MCP session continuity + "context": { "ui": "buyer_dashboard" }, // Application-level context echoed back + "products": [...], // Task-specific data (flat, not nested) + "errors": [...] // Task-level errors/warnings +} +``` + +### MCP-Specific Fields +- **context_id**: Session identifier that you must manually manage +- **context**: Opaque initiator-provided metadata echoed by agents +- **status**: Same values as A2A protocol for consistency +- Task-specific fields (e.g., `products`, `media_buy_id`, `creatives`) are at the top level, not wrapped in a `data` object + +**Status Handling**: See [Task Lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) for complete status handling patterns. + +## Available Tools + +All AdCP tasks are available as MCP tools: + +### Protocol Tools +```javascript +await mcp.call('get_adcp_capabilities', {...}); // Discover agent capabilities (start here) +``` + +### Media Buy Tools +```javascript +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 +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.0.13/media-buy) and [Signals](/dist/docs/3.0.13/signals/overview) sections. + +## Async Operations via MCP Tasks + +AdCP uses [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) for long-running operations over MCP. This removes the LLM from the polling path — the client handles task lifecycle at the protocol level, and the model only sees the final result. + +:::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 **webhooks** or **polling via `tasks/get`** instead — these work with any MCP client. See [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for transport-independent patterns. + +MCP Tasks are the right choice when you control the MCP client (e.g., building your own orchestrator with `@modelcontextprotocol/sdk`) or when client support matures. +::: + +### 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 +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 +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 +{ + "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 via `tasks/get`, retrieve the result via `tasks/result` + +### Invoking a Tool as a Task + +Include the `task` field in your `tools/call` request: + +```json +{ + "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 +{ + "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 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 a running task, send `tasks/cancel` with the `taskId`. + +### AdCP Status Mapping + +AdCP uses a richer set of statuses than MCP Tasks. When serving over MCP, AdCP statuses map to MCP Task statuses: + +| 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 polling within the MCP session, but some AdCP operations outlive a single session (e.g., a media buy that takes 24 hours for publisher approval). For these, combine MCP Tasks with `push_notification_config`: + +```json +{ + "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 status within the session. If the session ends before the task completes, the webhook delivers the result independently. See [Push Notifications](/dist/docs/3.0.13/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 +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 +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 +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 — the client handles polling via tasks/get +``` + +**Webhook POST format:** +```json +{ + "task_id": "task_456", + "status": "completed", + "timestamp": "2025-01-22T10:30:00Z", + "result": { + "media_buy_id": "mb_12345", + "packages": [...] + } +} +``` + +**Note:** This example follows the recommended URL-based routing pattern where `task_type` and `operation_id` are passed in the URL (e.g., `/webhooks/adcp/create_media_buy/op_456`). While the schema still supports these fields in the payload for backward compatibility, they are deprecated. + +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.0.13/core/mcp-webhook-payload.json) envelope includes: + +**Required fields:** +- `task_id` — Unique task identifier for correlation +- `status` — Current task status (completed, failed, working, input-required, etc.) +- `timestamp` — ISO 8601 timestamp when webhook was generated + +**Optional fields:** +- `domain` — AdCP domain ("media-buy" or "signals") +- `context_id` — Conversation/session identifier +- `message` — Human-readable context about the status change + +**Deprecated fields (supported but not recommended):** +- `task_type` — Task name (e.g., "create_media_buy", "sync_creatives") - ⚠️ **Deprecated:** See [URL-Based Routing](#best-practice-url-based-routing) +- `operation_id` — Correlates a sequence of updates for the same operation - ⚠️ **Deprecated:** See [URL-Based Routing](#best-practice-url-based-routing) + +**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.0.13/core/async-response-data.json) + +#### Webhook Handler Example + +```javascript +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 +// Check status of specific task +const taskStatus = await session.pollTask('task_456', true); +if (taskStatus.status === 'completed') { + console.log('Result:', taskStatus.result); +} + +// State reconciliation +const reconciliation = await session.reconcileState(); +if (reconciliation.missing_from_client.length > 0) { + console.log('Found orphaned tasks:', reconciliation.missing_from_client); + // 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 +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 a task 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, but requires client support | + +### 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 +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.0.13/building/by-layer/L3/webhooks) for payload formats and authentication. + +### Option 2: Polling (backup) + +Use `tasks/get` as a backup for `submitted` operations, or when you can't expose a webhook endpoint: + +```javascript +async function pollForResult(session, taskId, pollInterval = 30000) { + while (true) { + const response = await session.pollTask(taskId, 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 +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 +// 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 + +### Tool Discovery +```javascript +// 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.0.13/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 +{ + "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 +// 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.0.13/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 version negotiation, use [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities#version-negotiation) with the `adcp_major_version` field. +::: + +**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 +// 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.0.13/building/operating/transport-errors). + +```javascript +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.0.13/building/by-layer/L3/task-lifecycle) for status handling and workflows +- **Task Reference**: See [Media Buy Tasks](/dist/docs/3.0.13/media-buy) and [Signals](/dist/docs/3.0.13/signals/overview) +- **Protocol Comparison**: Compare with [A2A integration](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/task-lifecycle) - this guide focuses on MCP transport specifics only.** \ No newline at end of file diff --git a/dist/docs/3.0.13/building/operating-an-agent.mdx b/dist/docs/3.0.13/building/operating-an-agent.mdx new file mode 100644 index 0000000000..94a49a49a2 --- /dev/null +++ b/dist/docs/3.0.13/building/operating-an-agent.mdx @@ -0,0 +1,128 @@ +--- +title: Operating an Agent +sidebarTitle: Operating an Agent +description: "What sits behind a protocol-compliant agent — products, activation, hosting, and whether to partner, self-host, or build." +"og:title": "AdCP — Operating an Agent" +--- + +The 2–8 minute [Build an Agent](/dist/docs/3.0.13/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 `adagents.json`. Downtime or URL drift breaks buyer discovery. | Cloud VM, container service (Cloud Run, ECS, Fly.io), or a managed hosting platform. | +| **Discovery registration** | Tells buyer agents your agent exists and which protocols it supports. Handled via `adagents.json` on your domain — not through a central registry. | Publish `adagents.json` at your domain root. See [How Agents Communicate](/dist/docs/3.0.13/building/concepts/how-agents-communicate) and [Authorized Properties](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/building/verification/validate-your-agent)** — The compliance suite referenced above +- **[Security](/dist/docs/3.0.13/building/by-layer/L1/security)** — The normative reference for HMAC, idempotency, SSRF, and governance verification +- **[How Agents Communicate](/dist/docs/3.0.13/building/concepts/how-agents-communicate)** — Discovery via `adagents.json` and `brand.json` +- **[Seller Integration](/dist/docs/3.0.13/building/operating/seller-integration)** — Patterns for connecting an agent to an ad server +- **[Authorized Properties](/dist/docs/3.0.13/governance/property/authorized-properties)** — Who can sell what, and how that's declared diff --git a/dist/docs/3.0.13/building/operating/operating-an-agent.mdx b/dist/docs/3.0.13/building/operating/operating-an-agent.mdx new file mode 100644 index 0000000000..94a49a49a2 --- /dev/null +++ b/dist/docs/3.0.13/building/operating/operating-an-agent.mdx @@ -0,0 +1,128 @@ +--- +title: Operating an Agent +sidebarTitle: Operating an Agent +description: "What sits behind a protocol-compliant agent — products, activation, hosting, and whether to partner, self-host, or build." +"og:title": "AdCP — Operating an Agent" +--- + +The 2–8 minute [Build an Agent](/dist/docs/3.0.13/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 `adagents.json`. Downtime or URL drift breaks buyer discovery. | Cloud VM, container service (Cloud Run, ECS, Fly.io), or a managed hosting platform. | +| **Discovery registration** | Tells buyer agents your agent exists and which protocols it supports. Handled via `adagents.json` on your domain — not through a central registry. | Publish `adagents.json` at your domain root. See [How Agents Communicate](/dist/docs/3.0.13/building/concepts/how-agents-communicate) and [Authorized Properties](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/building/verification/validate-your-agent)** — The compliance suite referenced above +- **[Security](/dist/docs/3.0.13/building/by-layer/L1/security)** — The normative reference for HMAC, idempotency, SSRF, and governance verification +- **[How Agents Communicate](/dist/docs/3.0.13/building/concepts/how-agents-communicate)** — Discovery via `adagents.json` and `brand.json` +- **[Seller Integration](/dist/docs/3.0.13/building/operating/seller-integration)** — Patterns for connecting an agent to an ad server +- **[Authorized Properties](/dist/docs/3.0.13/governance/property/authorized-properties)** — Who can sell what, and how that's declared diff --git a/dist/docs/3.0.13/building/operating/orchestrator-design.mdx b/dist/docs/3.0.13/building/operating/orchestrator-design.mdx new file mode 100644 index 0000000000..a98f4bf768 --- /dev/null +++ b/dist/docs/3.0.13/building/operating/orchestrator-design.mdx @@ -0,0 +1,528 @@ +--- +title: Orchestrator Design +description: "AdCP orchestrator design: state machine patterns, persistent operation tracking, async-first architecture, and reconciliation for multi-vendor campaign workflows." +"og:title": "AdCP — Orchestrator Design" +--- + +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 +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 +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 +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 +async def reconcile_with_server(self, adcp_client): + """Sync local state with server using tasks/list""" + server_tasks = await adcp_client.call('tasks/list', { + '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 +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 +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 +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('tasks/get', { + '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 +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 +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 +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/governance/campaign/tasks/report_plan_outcome) | No feedback loop — governance agent cannot improve future recommendations | + +See the [media buy governance workflow](/dist/docs/3.0.13/media-buy/index#governance) for the complete sequence with code examples, and the [seller integration guide](/dist/docs/3.0.13/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 +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 +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 +@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 +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.0.13/building/by-layer/L3/task-lifecycle) for status handling +- **Webhooks**: See [Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks) for push notifications +- **Security**: See [Security](/dist/docs/3.0.13/building/by-layer/L1/security) for multi-tenant security diff --git a/dist/docs/3.0.13/building/operating/seller-integration.mdx b/dist/docs/3.0.13/building/operating/seller-integration.mdx new file mode 100644 index 0000000000..cacb54d1aa --- /dev/null +++ b/dist/docs/3.0.13/building/operating/seller-integration.mdx @@ -0,0 +1,329 @@ +--- +title: Making your inventory available to AI agents +sidebarTitle: Seller integration +description: "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." +"og:title": "AdCP — Making your inventory available to AI agents" +--- + +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 three parts: + + + +### Make your agent discoverable + +Publish an `adagents.json` file at your domain root. This file declares your properties and the agents authorized to sell your inventory — similar to how `ads.txt` works for supply chain transparency. + +```json +{ + "version": "1.0", + "properties": [ + { + "domain": "publisher.example.com", + "agents": [ + { + "agent_url": "https://ads.publisher.example.com", + "relationship": "direct", + "supported_protocols": ["media_buy", "creative"] + } + ] + } + ] +} +``` + +Buyer agents check `adagents.json` to find authorized sales agents, verify relationships, and discover which protocol domains you support. + + +This shows a simplified structure. The full adagents.json schema uses separate `properties` and `authorized_agents` arrays with a `delegation_type` field. See the [adagents.json tech spec](/dist/docs/3.0.13/governance/property/adagents) for the complete schema. + + +### 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 +{ + "buying_mode": "brief", + "brief": "Premium display placements for consumer electronics brand" +} +``` + +Your response includes structured product objects with pricing, formats, and delivery types: + +```json +{ + "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 +{ + "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": [ + { "placement_id": "pre_roll", "name": "Pre-roll (30s)" }, + { "placement_id": "host_read", "name": "Mid-roll host read (60s)" } + ], + "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.0.13/media-buy/product-discovery/collections-and-installments) for the full content model and [Media products](/dist/docs/3.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.0.13/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 explicit accounts — set `require_operator_auth: true` in [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities) so each operator authenticates independently. +- **Open platforms** (publishers, SSPs) can use implicit accounts — the agent is trusted and declares accounts via `sync_accounts`. + +See [Accounts and Agents](/dist/docs/3.0.13/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 provision test accounts and validate the full integration — product discovery, media buy creation, delivery reporting — before committing real spend. Without sandbox, buyers must test against live inventory, which slows adoption and increases onboarding friction. See [Sandbox mode](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/media-buy/product-discovery/collections-and-installments). For exclusivity and sponsorship patterns, see [Media products](/dist/docs/3.0.13/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.0.13/governance/property/specification) for the full schema and the [adagents.json tech spec](/dist/docs/3.0.13/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.0.13/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.0.13/accounts/tasks/sync_governance)), the seller MUST call [`check_governance`](/dist/docs/3.0.13/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 +// 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.0.13/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.0.13/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.0.13/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.0.13/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. + + diff --git a/dist/docs/3.0.13/building/operating/storyboard-troubleshooting.mdx b/dist/docs/3.0.13/building/operating/storyboard-troubleshooting.mdx new file mode 100644 index 0000000000..8e8816a862 --- /dev/null +++ b/dist/docs/3.0.13/building/operating/storyboard-troubleshooting.mdx @@ -0,0 +1,132 @@ +--- +title: Storyboard troubleshooting +description: "Common failure patterns when running AdCP compliance storyboards — missing fixtures, signature challenges, envelope drift, context echo, capability mismatches, and state-machine error codes." +"og:title": "AdCP — Storyboard troubleshooting" +--- + +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:** Implement `comply_test_controller` and honor the seed scenarios declared in the storyboard's `fixtures:` block. 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. + +See [Compliance test controller — Scenarios](/dist/docs/3.0.13/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.0.13/building/by-layer/L1/security#signed-requests-transport-layer) for the challenge-header format and the [transport-error binding order](/dist/docs/3.0.13/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.0.13/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.0.13/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` 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` in the test kit so the runner uses Bearer auth, 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. + +## Bearer-only agent: no auth mechanism contributed (assert_mechanism) + +```json +{ + "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 either prior phase. This is the most common failure for Bearer-only (API-key-only) agents, 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"` — it is skipped entirely unless the test kit declares an API key. The `oauth_discovery` phase 404s on `/.well-known/oauth-protected-resource/...` (expected for Bearer-only agents; those failures are silently ignored by the runner). With both 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`, which is the specific Bearer value the `api_key_path` phase sends during its positive-key and invalid-key probes. These are not the same token and are not interchangeable. + +**Fix:** Every AdCP 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 +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' }, + } + }) +}) +``` + +Agents that only accept their own production token skip `api_key_path` (no test-kit key matches), fail `oauth_discovery` (no PRM), and land at `actual: []` in `assert_mechanism`. Adding the demo key to your allowed-keys 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.0.13/building/cross-cutting/known-ambiguities#prm-required-for-non-oauth-agents) for background on why the carve-out exists and how the `api_key_path` / `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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/building/operating/transport-errors.mdx b/dist/docs/3.0.13/building/operating/transport-errors.mdx new file mode 100644 index 0000000000..5dcfeae566 --- /dev/null +++ b/dist/docs/3.0.13/building/operating/transport-errors.mdx @@ -0,0 +1,701 @@ +--- +title: Transport Error Mapping +description: "How AdCP structured errors travel over MCP and A2A transports: extraction paths, JSON-RPC codes, recovery behavior, and client implementation requirements." +"og:title": "AdCP — Transport Error Mapping" +--- + +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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +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 +{ + "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.0.13/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 +{ + "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.0.13/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.0.13/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 +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 +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.0.13/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 +// 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/error-details/rate-limited.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/rate-limited.json) +- [`/schemas/3.0.13/error-details/budget-too-low.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/budget-too-low.json) +- [`/schemas/3.0.13/error-details/audience-too-small.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/audience-too-small.json) +- [`/schemas/3.0.13/error-details/account-setup-required.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/account-setup-required.json) +- [`/schemas/3.0.13/error-details/creative-rejected.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/creative-rejected.json) +- [`/schemas/3.0.13/error-details/policy-violation.json`](https://adcontextprotocol.org/schemas/3.0.13/error-details/policy-violation.json) +- [`/schemas/3.0.13/error-details/conflict.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/error-details/vendor-error-codes.json) by submitting a PR + +```javascript +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 +// 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.0.13/building/by-layer/L3/error-handling) — error schema, standard codes, recovery strategies +- [MCP Guide](/dist/docs/3.0.13/building/by-layer/L0/mcp-guide) — MCP transport integration +- [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) — A2A transport integration +- [A2A Response Format](/dist/docs/3.0.13/building/by-layer/L0/a2a-response-format) — canonical A2A response structure diff --git a/dist/docs/3.0.13/building/schemas-and-sdks.mdx b/dist/docs/3.0.13/building/schemas-and-sdks.mdx new file mode 100644 index 0000000000..768ef63fe1 --- /dev/null +++ b/dist/docs/3.0.13/building/schemas-and-sdks.mdx @@ -0,0 +1,339 @@ +--- +title: Schemas and SDKs +description: "AdCP JSON schemas and SDKs for JavaScript, Python, and Go. Download schemas from GitHub or fetch at runtime. Includes setup for AI coding agents using Cursor, Windsurf, and Claude." +"og:title": "AdCP — Schemas and SDKs" +--- + +Get started integrating with AdCP using our schemas and official SDKs. + +## Schema Access + +AdCP schemas are available from two sources: + +| Source | URL | Best For | +|--------|-----|----------| +| Website | `https://adcontextprotocol.org/schemas/3.0.13/` | 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 +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/client` `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 +# 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/.*$' \ + --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/client` `sync-schemas` command performs this verification automatically when the sidecars are present. + +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.0.13/building/verification/compliance-catalog) for every protocol and specialism an agent can claim. + +### Common Schemas + +| Schema | URL | +|--------|-----| +| Product | `https://adcontextprotocol.org/schemas/3.0.13/core/product.json` | +| Media Buy | `https://adcontextprotocol.org/schemas/3.0.13/core/media-buy.json` | +| Creative Format | `https://adcontextprotocol.org/schemas/3.0.13/core/format.json` | +| Schema Registry | `https://adcontextprotocol.org/schemas/3.0.13/index.json` | + +### For AI Coding Agents + + +Point your AI coding agent to **[https://docs.adcontextprotocol.org/mcp](https://docs.adcontextprotocol.org/mcp)** for MCP integration documentation. + + +## Client SDKs + +AdCP provides official SDKs for JavaScript/TypeScript, Python, and Go. These work for both client and server implementations. + +### AdCP 3.0 support + +All three official SDKs ship AdCP 3.0. Pin to (or above) the minimum listed below and validate with the [storyboard test suite](/dist/docs/3.0.13/building/verification/validate-your-agent). + +| SDK | 3.0-compatible from | Package | +|---|---|---| +| `@adcp/client` (JS/TS) | `5.13.0` | [npm](https://www.npmjs.com/package/@adcp/client) · [releases](https://github.com/adcontextprotocol/adcp-client/releases) | +| `adcp` (Python) | `4.0.0` | [PyPI](https://pypi.org/project/adcp/) · [releases](https://github.com/adcontextprotocol/adcp-python/releases) | +| `adcp-go` (Go) | `v1.0.0` | [releases](https://github.com/adcontextprotocol/adcp-go/releases) | + +If you are on an earlier major version, upgrade before working through the [3.0 migration guide](/dist/docs/3.0.13/reference/migration/index). + +### JavaScript / TypeScript + +[![npm version](https://img.shields.io/npm/v/@adcp/client)](https://www.npmjs.com/package/@adcp/client) + +```bash +npm install @adcp/client +``` + +```javascript +import { ADCPClient } from '@adcp/client'; + +const client = new ADCPClient({ + agentUrl: 'https://sales.example.com' +}); + +const products = await client.getProducts({ + brief: 'Video campaign for pet owners' +}); +``` + +**Resources:** +- [NPM Package](https://www.npmjs.com/package/@adcp/client) +- [GitHub Repository](https://github.com/adcontextprotocol/adcp-client) + +**Package exports:** +- `@adcp/client` — Main API +- `@adcp/client/testing` — Buyer-side storyboard runner (`runStoryboard`, `comply`, `testAgent`) and seller-side controller scaffold (`createComplyController` — see [Get Test-Ready](/dist/docs/3.0.13/building/verification/get-test-ready)) +- `@adcp/client/server` — Low-level server helpers (MCP tool registration, state stores, `handleTestControllerRequest`) +- `@adcp/client/advanced` — Advanced API features +- `@adcp/client/types` — TypeScript type definitions + +### Python + +[![PyPI version](https://img.shields.io/pypi/v/adcp)](https://pypi.org/project/adcp/) + +```bash +pip install adcp +``` + +```python +from adcp import ADCPClient + +client = ADCPClient(agent_url='https://sales.example.com') + +products = client.get_products( + 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 +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. + +### JavaScript CLI + +```bash +npx @adcp/client@latest --help +npx @adcp/client@latest get-products --agent https://sales.example.com --brief "CTV campaign" +``` + +### Python CLI + +```bash +uvx adcp --help +uvx adcp get-products --agent https://sales.example.com --brief "CTV campaign" +``` + +## 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.0.13/`, `/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 +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 +const schema = await fetch( + 'https://adcontextprotocol.org/schemas/3.0.13/core/product.json' +); +``` + +### SDK Type Generation + +```bash +# 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 +# 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 task schemas +├── core/ # Modular schemas with $ref +├── media-buy/ +└── index.json # Schema registry +``` + +### 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 | +| `bundled/core/` | tasks-get, tasks-list | + +See the [schema registry](https://adcontextprotocol.org/schemas/3.0.13/index.json) for all available schemas. + +## Version Discovery + +```bash +# Get current version +curl https://adcontextprotocol.org/schemas/3.0.13/index.json | jq '.adcp_version' +``` + +Check [Release Notes](/dist/docs/3.0.13/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. + diff --git a/dist/docs/3.0.13/building/understanding/adcp-vs-openrtb.mdx b/dist/docs/3.0.13/building/understanding/adcp-vs-openrtb.mdx new file mode 100644 index 0000000000..43e3ed29fe --- /dev/null +++ b/dist/docs/3.0.13/building/understanding/adcp-vs-openrtb.mdx @@ -0,0 +1,122 @@ +--- +title: AdCP and OpenRTB +sidebarTitle: AdCP vs OpenRTB +description: "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." +"og:title": "AdCP — AdCP and OpenRTB" +--- + +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.0.13/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. + +**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.0.13/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 +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. + + + diff --git a/dist/docs/3.0.13/building/understanding/how-agents-communicate.mdx b/dist/docs/3.0.13/building/understanding/how-agents-communicate.mdx new file mode 100644 index 0000000000..7c08e4c510 --- /dev/null +++ b/dist/docs/3.0.13/building/understanding/how-agents-communicate.mdx @@ -0,0 +1,180 @@ +--- +title: How AI agents communicate ad specs across platforms +sidebarTitle: How agents communicate +description: "How AI advertising agents discover inventory, exchange campaign data, and execute buys across platforms using AdCP's standardized task schemas and MCP transport." +"og:title": "AdCP — How AI agents communicate ad specs across platforms" +--- + +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 +{ + "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 +{ + "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 +{ + "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. + + diff --git a/dist/docs/3.0.13/building/understanding/index.mdx b/dist/docs/3.0.13/building/understanding/index.mdx new file mode 100644 index 0000000000..35c6c6b8b0 --- /dev/null +++ b/dist/docs/3.0.13/building/understanding/index.mdx @@ -0,0 +1,108 @@ +--- +title: Why AdCP +description: "Why AdCP exists: the fragmentation problem across RTB, platform APIs, and direct IO — and how a universal agent protocol for advertising solves it." +"og:title": "AdCP — Why AdCP" +--- + +## 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 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. + + diff --git a/dist/docs/3.0.13/building/understanding/industry-landscape.mdx b/dist/docs/3.0.13/building/understanding/industry-landscape.mdx new file mode 100644 index 0000000000..4018ef5e57 --- /dev/null +++ b/dist/docs/3.0.13/building/understanding/industry-landscape.mdx @@ -0,0 +1,140 @@ +--- +title: AI advertising standards landscape +sidebarTitle: Industry landscape +description: "AI advertising standards landscape: how AdCP, OpenRTB, MCP, and A2A relate. Comparison of protocols, standards bodies, and their roles in agentic advertising." +"og:title": "AdCP — AI advertising standards landscape" +--- + +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.0.13/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. + + diff --git a/dist/docs/3.0.13/building/understanding/protocol-comparison.mdx b/dist/docs/3.0.13/building/understanding/protocol-comparison.mdx new file mode 100644 index 0000000000..e1baaf652a --- /dev/null +++ b/dist/docs/3.0.13/building/understanding/protocol-comparison.mdx @@ -0,0 +1,236 @@ +--- +title: Protocol Comparison +description: "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." +"og:title": "AdCP — Protocol Comparison" +--- + +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** | [MCP Tasks](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks) | SSE streaming | +| **Webhooks** | `push_notification_config` in tool args | Native PushNotificationConfig | +| **Task Management** | MCP Tasks (`tasks/get`, `tasks/result`, `tasks/cancel`) | Native task lifecycle | +| **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 +{ + "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) | Poll frequently, 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.0.13/building/by-layer/L3/task-lifecycle) for complete status handling guide. + +## Transport Format Differences + +Same status and data, different packaging: + +### MCP Response Format +```json +{ + "status": "input-required", + "message": "I need your budget and target audience", + "context_id": "ctx-123", + "products": [], + "suggestions": ["budget", "audience"] +} +``` + +### A2A Response Format +```json +{ + "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 handle async operations with the same status progression: +`submitted` → `working` → `completed`/`failed` + +### MCP Async Pattern (MCP Tasks) +```javascript +// Task-augmented tool call — returns CreateTaskResult immediately +const createResult = await mcp.callTool({ + name: "create_media_buy", + arguments: { + buyer_ref: "nike_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 +const result = await mcp.getTaskResult({ taskId: "task-456" }); +// result = { content: [...], isError: false } +``` + +### A2A Async Pattern +```javascript +// Initial response with native task tracking +{ + "status": "submitted", + "taskId": "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 +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 +// 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 +// 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 +{ + "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.0.13/building/by-layer/L0/mcp-guide) for tool calls and context management +- **A2A Guide**: See [A2A Guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide) for artifacts and streaming +- **Both protocols**: Provide the same capabilities with unified status handling diff --git a/dist/docs/3.0.13/building/understanding/security-model.mdx b/dist/docs/3.0.13/building/understanding/security-model.mdx new file mode 100644 index 0000000000..5d8b4d48b4 --- /dev/null +++ b/dist/docs/3.0.13/building/understanding/security-model.mdx @@ -0,0 +1,275 @@ +--- +title: Security Model +sidebarTitle: Security Model +description: "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." +"og:title": "AdCP — Security Model" +--- + +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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. 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.0.13/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.0.13/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 +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.0.13/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.0.13/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.0.13/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. + +**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. + +### Layer 3: Idempotency — at-most-once execution + +Every mutating AdCP request carries a required [`idempotency_key`](/dist/docs/3.0.13/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.0.13/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.0.13/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 return a signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}`. 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 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.0.13/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.0.13/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.0.13/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.0.13/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 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** — publisher-attested in `brand.json` `agents[].signing_keys[]`, fetched from the publisher's own `/.well-known`. +- **`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.0.13/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[].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.0.13/building/by-layer/L1/security)** — Normative rules for HMAC, idempotency, SSRF, agent/account isolation, and governance verification +- **[Embedded Human Judgment](/dist/docs/3.0.13/governance/embedded-human-judgment)** — The five principles that keep humans in the loop on decisions with real consequences +- **[Trusted Match Protocol](/dist/docs/3.0.13/trusted-match)** — The two-call decomposition (Context Match / Identity Match) that delivers structural privacy separation at serve time +- **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** — Signature format, replay windows, rotation +- **[Signed Governance Context](/dist/docs/3.0.13/building/by-layer/L1/security#signed-governance-context)** — The 15-step verification checklist +- **[Operating an Agent](/dist/docs/3.0.13/building/operating/operating-an-agent)** — Credential management, monitoring, and incident response as operating concerns +- **[How Agents Communicate](/dist/docs/3.0.13/building/concepts/how-agents-communicate)** — `adagents.json`, `brand.json`, and the discovery trust chain diff --git a/dist/docs/3.0.13/building/validate-your-agent.mdx b/dist/docs/3.0.13/building/validate-your-agent.mdx new file mode 100644 index 0000000000..c8575aeab4 --- /dev/null +++ b/dist/docs/3.0.13/building/validate-your-agent.mdx @@ -0,0 +1,262 @@ +--- +title: Validate Your Agent +sidebarTitle: Validate Your Agent +description: "Test your AdCP agent with storyboards — from the CLI or through Addie." +"og:title": "AdCP — Validate Your Agent" +--- + +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.0.13/building/by-layer/L0/schemas#one-shot-protocol-bundle) for how to fetch them offline. + +## 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.0.13/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.0.13/building/verification/compliance-catalog) for the full taxonomy. + +## Setup + +Save your agent as a named alias so you can reference it by name: + +```bash +npx @adcp/client@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/client@latest storyboard run http://localhost:3001/mcp media_buy_seller` + + +## Run a storyboard + +### 1. List available storyboards + +```bash +npx @adcp/client@latest storyboard list +``` + +Each storyboard targets a specific agent type. The [Build an Agent](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) page maps skills to their matching storyboards. + +### 2. Preview what a storyboard tests + +```bash +npx @adcp/client@latest storyboard show media_buy_seller +``` + +This shows the phases, steps, and validations without running anything. + +### 3. Run the storyboard + +```bash +npx @adcp/client@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 +npx @adcp/client@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 +npx @adcp/client@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 +npx @adcp/client@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.0.13/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 | + +## 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 +{ + "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.0.13/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.0.13/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 +npx @adcp/client@latest --save-auth my-agent https://my-agent.example/mcp +npx @adcp/client@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.0.13/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 +# Cross-tenant (full MUST) +npx @adcp/client@latest fuzz my-agent \ + --auth-token $TENANT_A_TOKEN \ + --auth-token-cross-tenant $TENANT_B_TOKEN + +# Baseline (partial coverage) +npx @adcp/client@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/client` 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.0.13/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/client@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/client@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/client@latest storyboard list` | List all available storyboards | +| `npx @adcp/client@latest storyboard show ` | Preview storyboard structure | +| `npx @adcp/client@latest storyboard run [id]` | Run one storyboard, or all matching if no ID given | +| `npx @adcp/client@latest storyboard step ` | Run a single step | +| `npx @adcp/client@latest [tool] [payload]` | Call any tool directly | +| `npx @adcp/client@latest --save-auth ` | Save agent alias | +| `npx @adcp/client@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.0.13/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.0.13/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.0.13/building/by-layer/L3/comply-test-controller)** — Implement deterministic testing for full lifecycle coverage +- **[Task lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling +- **[Error handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery diff --git a/dist/docs/3.0.13/building/verification/aao-verified.mdx b/dist/docs/3.0.13/building/verification/aao-verified.mdx new file mode 100644 index 0000000000..5c9758f979 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/aao-verified.mdx @@ -0,0 +1,305 @@ +--- +title: AAO Verified +sidebarTitle: AAO Verified +description: "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." +"og:title": "AdCP — AAO Verified" +--- + +**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 gate is normative: every comply_test_controller request includes `account: { sandbox: true }`, and the seller MUST verify the targeted account is sandbox by looking up the persisted account record — not by trusting the field. See [comply_test_controller](/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller) for the dev-side affordance; AAO 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.0.13/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.0.13/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", "live"]`. 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 runs 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 (Sandbox) qualifier attests that the seller's prod stack handles all of them correctly under flagged traffic, not just the ones with a real-money observability path. 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. + +## How agents earn each axis + +```json +// agent declares its claims in get_adcp_capabilities +{ + "supported_protocols": ["media_buy", "creative"], + "specialisms": [ + "sales-broadcast-tv", + "sales-guaranteed", + "creative-ad-server" + ] +} +``` + +### 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.0.13/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 +{ + "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. + +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 with all declared-specialism storyboards passing against the registered production URL under `account.sandbox: true` + active membership. The seller's prod stack must additionally pass the `comply-controller-mode-gate` storyboard (refuses controller dispatch against live-mode accounts — the seller-side sandbox isolation contract). +- **Active** — re-checked every heartbeat; JWT auto-renewed. +- **Degraded** — first storyboard 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-mode account accepting a sandbox-flagged controller call) 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 storyboards (including the mode-gate check) reissue (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; (Live) not enrolled, or no (Live) path exists for the agent's specialisms. Common for test agents, sandboxes, and pre-production rollouts. +- **(Live) only** — real production traffic observed healthy across the rolling window. Common for SDK-built agents whose wire-format correctness is guaranteed by the SDK, and for production-only platforms with no test-mode surface. The eight observability checks already exercise wire format, filters, lifecycle, and scope, so requiring a parallel storyboard pass would be busywork. +- **(Spec + Live)** — the strongest claim. Both axes verified independently. +- **Neither** + +The two axes are evaluated independently. A storyboard regression revokes (Spec) without affecting (Live); an observability check failure revokes (Live) 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 (Live). 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 + Live)` — 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 + Live)`. + +## 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 + Live)` 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 +# 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 AAO 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 (Live) 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 agents I can actually buy through"** → filter by `verification_modes contains 'live'` +- **"Show me agents with both"** → filter by both + +Both queries are valid. Buyers comparing options use (Live); orchestrator developers integrating new agents use (Spec). + +### brand.json enrichment + +When AAO serves brand.json data for a registered brand, agent entries get an `aao_verification` block with full per-version detail: + +```json +"aao_verification": { + "verified": true, + "verified_at": "2026-04-29T12:34:56.000Z", + "badges": [ + { "role": "media-buy", "adcp_version": "3.1", "verification_modes": ["spec", "live"], "verified_at": "..." }, + { "role": "media-buy", "adcp_version": "3.0", "verification_modes": ["spec"], "verified_at": "..." } + ], + "roles": ["media-buy"], + "modes_by_role": { "media-buy": ["spec", "live"] }, + "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 runs Live for them when only the 3.1 badge has Live). + +`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 AAO'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 AAO. 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 [`comply-controller-mode-gate`](https://adcontextprotocol.org/compliance/latest/universal/comply-controller-mode-gate) storyboard, which exercises the seller-side isolation contract (refuse controller dispatch against live-mode accounts). +6. The AAO compliance heartbeat issues **AAO Verified (Sandbox)** when the full applicable storyboard suite 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.0.13/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 `account.sandbox: true` on every `comply_test_controller` request (when present). Defense-in-depth on top of the seller-side gate; a request asserting `sandbox: false` schema-rejects. +- **[`comply-controller-mode-gate` storyboard (#4028 / #4384)](https://github.com/adcontextprotocol/adcp/issues/4028)** — verifies sellers correctly refuse controller dispatch against live-mode accounts. Keystone of the (Sandbox) isolation contract. +- **[UNKNOWN_SCENARIO grading (#4226 / #4228)](https://github.com/adcontextprotocol/adcp/issues/4226)** — sellers MAY implement controller selectively; the runner grades absent operations as `not_applicable` rather than `failed`. 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.0.13/building/verification/conformance) — defines what *conformant* means via the storyboards. The (Spec) axis verifies your agent matches that specification. +- [Compliance Catalog](/dist/docs/3.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/building/verification/addie-socket-mode.mdx b/dist/docs/3.0.13/building/verification/addie-socket-mode.mdx new file mode 100644 index 0000000000..41e05d5ef8 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/addie-socket-mode.mdx @@ -0,0 +1,196 @@ +--- +title: Pair-program with Addie (Socket Mode) +sidebarTitle: Pair with Addie +description: "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." +"og:title": "AdCP — Pair-program with Addie" +--- + +**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.0.13/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 +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.0.13/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.0.13/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 +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.0.13/protocol/get_adcp_capabilities) — the discovery tool every storyboard starts with +- [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) — full list of available storyboards +- [Get Test-Ready](/dist/docs/3.0.13/building/verification/get-test-ready) — what your agent needs in place before any storyboard can pass +- [AAO Verified](/dist/docs/3.0.13/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) diff --git a/dist/docs/3.0.13/building/verification/compliance-catalog.mdx b/dist/docs/3.0.13/building/verification/compliance-catalog.mdx new file mode 100644 index 0000000000..6228132bd3 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/compliance-catalog.mdx @@ -0,0 +1,354 @@ +--- +title: Compliance Catalog +sidebarTitle: Compliance Catalog +description: "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." +"og:title": "AdCP — Compliance Catalog" +--- + +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.0.13/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. + +{/* Lint: scripts/lint-universal-storyboard-doc-parity.cjs keeps this table in sync with static/compliance/source/universal/. Add a row (kebab-case slug) when you add a graded storyboard; remove the row when one is deleted. The build fails on drift. */} + +| Storyboard | Purpose | +|-----------|---------| +| `capability-discovery` | `get_adcp_capabilities` shape, protocol/specialism declarations, version advertising | +| `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` | +| `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; gated on `get_signals` + `activate_signal` | +| `idempotency` | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | +| `security` | **Authentication baseline — unauth rejection, API key enforcement, OAuth discovery + RFC 9728 audience binding.** See [Authentication](/dist/docs/3.0.13/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. | +| `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. | +| `pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_creatives` response from a continuation page through to terminal. | +| `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` rows are precondition-gated rather than capability-gated: each phase grades `not_applicable` when its precondition is not met (the seller supports all three billing values; or the test kit does not declare a passthrough-only caller). Sellers wanting full coverage of the per-agent 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.0.13/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 | + + +Support for the [compliance test controller](/dist/docs/3.0.13/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 — accepts `sync_governance`, calls `check_governance`, and propagates approvals, conditions, and denials unchanged. Optional claim; sellers that don't claim it skip the governance scenarios as not_applicable. | +| `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 | +| `signal-marketplace` | stable | Marketplace signal agent reselling third-party data | + +### 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 | + + +**Coming in 3.1.** `measurement-verification` (third-party viewability, attribution, brand-safety, and SI-outcome verification) is scheduled for 3.1 under a dedicated `measurement` protocol. + + +### 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. + +| If you… | Set | +|---|---| +| Accept RFPs, generate proposals, and finalize to committed status before IO | `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 +// Full-service guaranteed seller — proposal lifecycle graded +{ + "supported_protocols": ["media_buy"], + "specialisms": ["sales-guaranteed"], + "media_buy": { + "supports_proposals": true + } +} +``` + +```jsonc +// 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 | +| `signal-marketplace` | stable | Marketplace signal agent reselling third-party data | + +### 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 | + + +**Coming in 3.1.** `measurement-verification` (third-party viewability, attribution, brand-safety, and SI-outcome verification) is scheduled for 3.1 under a dedicated `measurement` protocol. + + +### 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.0.13/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 +{ + "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.0.13/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.0.13/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 agent builds the segment on demand from models, composites, or buyer-supplied inputs. Use this when no `adagents.json` authorization chain applies — the segment is agent-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.0.13/building/verification/validate-with-mock-fixtures)** for the complementary pre-staging gate. + diff --git a/dist/docs/3.0.13/building/verification/conformance.mdx b/dist/docs/3.0.13/building/verification/conformance.mdx new file mode 100644 index 0000000000..754d8f65c8 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/conformance.mdx @@ -0,0 +1,225 @@ +--- +title: Conformance Specification +sidebarTitle: Conformance Specification +description: "What 'AdCP-conformant' means, defined by the storyboards that verify it. Conformance is what the spec requires; verified is what the suite attests." +"og:title": "AdCP — Conformance Specification" +--- + +**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.0.13/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.0.13/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.0.13/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.0.13/building/verification/compliance-catalog) for the full taxonomy and [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) for how to run the suite locally. + +## Universal conformance + +Every agent MUST pass every storyboard below. + +{/* Lint: scripts/lint-universal-storyboard-doc-parity.cjs keeps this table in sync with static/compliance/source/universal/. Add a row (snake_case YAML id) when you add a graded storyboard; remove the row when one is deleted. The build fails on drift. */} + +| Storyboard | What it verifies | +|------------|------------------| +| [`capability_discovery`](https://adcontextprotocol.org/compliance/latest/universal/capability-discovery) | `get_adcp_capabilities` shape, protocol/specialism declarations, version advertising | +| [`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` | +| [`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; gated on `get_signals` + `activate_signal` | +| [`idempotency`](https://adcontextprotocol.org/compliance/latest/universal/idempotency) | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | +| [`security_baseline`](https://adcontextprotocol.org/compliance/latest/universal/security) | Unauth rejection, API key enforcement, 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`. | +| [`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 | +| [`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_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) | `cursor` ↔ `has_more` invariant on paginated `list_accounts` responses; storyboard bootstraps three accounts via `sync_accounts` and walks from a continuation page to a terminal page | +| [`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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/cross-cutting/known-ambiguities) instead of treating the mock as authoritative. + +- **[Storyboard troubleshooting](/dist/docs/3.0.13/building/operating/storyboard-troubleshooting)** — Error pattern → root cause → fix for the most common storyboard failures +- **[Known spec ambiguities](/dist/docs/3.0.13/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.0.13/building/verification/aao-verified)** — Continuous-observability verification of the seller's live ad-server integration +- **[Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog)** — Full taxonomy of protocols and specialisms with storyboard IDs +- **[Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent)** — How to run the suite +- **[Security Model](/dist/docs/3.0.13/building/concepts/security-model)** — Strategic framing for the five defense layers that the security storyboards enforce +- **[Security (implementation reference)](/dist/docs/3.0.13/building/by-layer/L1/security)** — Normative rules cited by the storyboards +- **[Versioning](/dist/docs/3.0.13/reference/versioning)** — Major-version support windows +- **[Known Limitations](/dist/docs/3.0.13/reference/known-limitations)** — Visible edges of the specification diff --git a/dist/docs/3.0.13/building/verification/get-test-ready.mdx b/dist/docs/3.0.13/building/verification/get-test-ready.mdx new file mode 100644 index 0000000000..542ddaf66d --- /dev/null +++ b/dist/docs/3.0.13/building/verification/get-test-ready.mdx @@ -0,0 +1,202 @@ +--- +title: Get Test-Ready +sidebarTitle: Get Test-Ready +description: "What a sales agent operator must have in place before running storyboards — capabilities, sandbox accounts, and the compliance test controller." +"og:title": "AdCP — Get Test-Ready" +--- + +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.0.13/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.0.13/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.0.13/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.0.13/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 +{ + "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.0.13/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.0.13/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: + +**Implicit 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. + +**Explicit accounts (`require_operator_auth: true`).** Accounts must be pre-provisioned by a human on your side. The runner calls `list_accounts` with a sandbox filter to discover pre-existing test accounts. 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.0.13/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 a first pass and for capability discovery, but [conformance](/dist/docs/3.0.13/building/verification/conformance) treats **deterministic mode** — full lifecycle walks enabled by the controller — as the bar for specialism coverage. + +`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.0.13/building/by-layer/L3/comply-test-controller) for scenario-by-scenario parameters and response shapes, and the [Compliance Catalog](/dist/docs/3.0.13/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 +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.0.13/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.0.13/building/by-layer/L4/choose-your-sdk). + + +```ts +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` in `'INVALID_TRANSITION' | 'NOT_FOUND' | 'FORBIDDEN' | 'INVALID_PARAMS'` from any adapter. + +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 +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 +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.0.13/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.0.13/building/verification/validate-your-agent)** — CLI, Addie workflows, and multi-instance verification +- **[Compliance test controller reference](/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller)** — Full scenario-by-scenario spec +- **[Sandbox mode](/dist/docs/3.0.13/media-buy/advanced-topics/sandbox)** — The two account model paths in depth +- **[Conformance](/dist/docs/3.0.13/building/verification/conformance)** — What "conformant" and "verified" mean once your runs pass diff --git a/dist/docs/3.0.13/building/verification/grading.mdx b/dist/docs/3.0.13/building/verification/grading.mdx new file mode 100644 index 0000000000..a53ca2c123 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/grading.mdx @@ -0,0 +1,83 @@ +--- +title: Auth Graders +sidebarTitle: Auth Graders +description: "AdCP CLI graders for RFC 9421 request-signing conformance, OAuth handshake diagnosis, and Ed25519/P-256 signing key generation and verification." +"og:title": "AdCP — Auth Graders" +--- + +`@adcp/sdk` 5.21+ ships CLI graders for authentication conformance. They are separate from the [compliance storyboards](/dist/docs/3.0.13/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 +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 +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 +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 +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.0.13/building/verification/validate-your-agent) — storyboard-based protocol compliance testing +- [Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication) — auth model overview, bearer tokens, RFC 9421 introduction +- [Security implementation reference](/dist/docs/3.0.13/building/by-layer/L1/security#signed-requests-transport-layer) — full RFC 9421 profile, verifier checklist, key publication rules diff --git a/dist/docs/3.0.13/building/verification/how-grading-works.mdx b/dist/docs/3.0.13/building/verification/how-grading-works.mdx new file mode 100644 index 0000000000..3c838df471 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/how-grading-works.mdx @@ -0,0 +1,134 @@ +--- +title: How grading works +sidebarTitle: How grading works +description: "How the AdCP compliance runner translates specialism declarations into a concrete set of graded storyboards — and how capability flags alter that set." +"og:title": "AdCP — How compliance grading works" +--- + +The [Conformance Specification](/dist/docs/3.0.13/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. + +## 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 +# /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 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 is skipped — the `skip` block will appear in runner output with `reason: not_applicable` — and does not contribute to `steps_failed`. + +```yaml +# /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. + + +**Schema status.** `requires_capability` is not yet defined in `storyboard-schema.yaml` — runners recognise it (the TS SDK reads and enforces the block) but scenario-authoring tooling that validates against the storyboard schema will flag it as an unknown field today. Adding it to the schema is tracked separately; until then, treat `requires_capability` as a stable runner-level extension that the schema lints will catch up to. + + + +## Worked example + +**Scenario:** Priya's StreamHaus platform claims `sales-guaranteed` and declares `media_buy.supports_proposals: true`. + +```json +{ + "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 accept 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 +{ + "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 +{ + "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. + + +## Grading verdicts at a glance + +| Outcome | `skip.reason` | Meaning | +|---------|---------------|---------| +| Scenario passed | — (no `skip` block) | All validations passed; `passed: true` at the step level | +| Scenario failed | — (no `skip` block) | One or more required validations failed; see `validations[]` for the failing field and `json_pointer` | +| Scenario skipped | `not_applicable` | Step was not run. Check `skip.detail` to distinguish: capability gate evaluated false, specialism not declared, or prerequisite not met | +| Required tool missing | `missing_tool` | 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) do not contribute to that counter. The `skip.detail` field is the human-readable string that names the specific skip cause. + +## 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.0.13/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.0.13/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.0.13/building/verification/conformance) — the three-layer obligation model and the normative storyboard index +- [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) — full taxonomy of protocols, specialisms, and universal storyboards +- [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) — running the suite locally with `@adcp/client` diff --git a/dist/docs/3.0.13/building/verification/storyboards-vs-scenarios.mdx b/dist/docs/3.0.13/building/verification/storyboards-vs-scenarios.mdx new file mode 100644 index 0000000000..90141253a7 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/storyboards-vs-scenarios.mdx @@ -0,0 +1,71 @@ +--- +title: Storyboards vs. scenarios — which is which +sidebarTitle: Storyboards vs. Scenarios +description: "Three things in AdCP share the word 'scenarios' and they aren't the same. Here's how to tell them apart." +"og:title": "AdCP — Storyboards vs. scenarios" +--- + +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.0.13/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.0.13/building/verification/conformance) calls these *"the truth"* — and means it. The AdCP Verified (Spec) badge is issued by running these YAML files through [`comply()`](/dist/docs/3.0.13/building/verification/validate-your-agent) and observing every assertion pass. + +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 storyboard run ` or interactively through [Addie](https://agenticadvertising.org). + +## `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.0.13/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 AAO 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 storyboard list` +2. Read the YAML: `npx @adcp/sdk storyboard show ` +3. Run it: `npx @adcp/sdk 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. diff --git a/dist/docs/3.0.13/building/verification/validate-with-mock-fixtures.mdx b/dist/docs/3.0.13/building/verification/validate-with-mock-fixtures.mdx new file mode 100644 index 0000000000..26fc3c162c --- /dev/null +++ b/dist/docs/3.0.13/building/verification/validate-with-mock-fixtures.mdx @@ -0,0 +1,138 @@ +--- +title: Validate adapter agents with mock upstream fixtures +sidebarTitle: Mock Upstream Fixtures +description: "Pre-staging gate for AdCP adapter agents — published mock upstreams plus traffic counters surface integration gaps before staging tests." +"og:title": "AdCP — Validate adapter agents with mock fixtures" +--- + + +**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.0.13/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/client`; 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.0.13/building/verification/validate-your-agent). + +## The four-step recipe + +```bash +# 1. Boot a mock upstream for your specialism +npx @adcp/client@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/client@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/client` (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/client`) 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 `@adcp/client` 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 +jobs: + validate-adapter: + runs-on: ubuntu-latest + services: + mock-upstream: + image: node:20 + ports: ['4250:4250'] + # Use a bootstrap script that runs `npx @adcp/client@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/client@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/client@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.0.13/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.0.13/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.0.13/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.0.13/building/by-layer/L4/build-an-agent)** — the language-agnostic guide to building an AdCP agent. +- **[Compliance catalog](/dist/docs/3.0.13/building/verification/compliance-catalog)** — the full taxonomy of universal / protocol / specialism storyboards. diff --git a/dist/docs/3.0.13/building/verification/validate-your-agent.mdx b/dist/docs/3.0.13/building/verification/validate-your-agent.mdx new file mode 100644 index 0000000000..c631469823 --- /dev/null +++ b/dist/docs/3.0.13/building/verification/validate-your-agent.mdx @@ -0,0 +1,270 @@ +--- +title: Validate your agent using storyboards +sidebarTitle: Validate Your Agent +description: "Test your AdCP agent with storyboards — from the CLI or through Addie." +"og:title": "AdCP — Validate your agent using storyboards" +--- + +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/verification/compliance-catalog) for the full taxonomy. + +## Setup + +Save your agent as a named alias so you can reference it by name: + +```bash +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 +npx @adcp/sdk@latest storyboard list +``` + +Each storyboard targets a specific agent type. The [Build an Agent](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) page maps skills to their matching storyboards. + +### 2. Preview what a storyboard tests + +```bash +npx @adcp/sdk@latest storyboard show media_buy_seller +``` + +This shows the phases, steps, and validations without running anything. + +### 3. Run the storyboard + +```bash +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 +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 +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 +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.0.13/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 | + +## 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 +{ + "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.0.13/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.0.13/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 +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.0.13/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 +# 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.0.13/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.0.13/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.0.13/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.0.13/building/by-layer/L3/comply-test-controller)** — Implement deterministic testing for full lifecycle coverage +- **[Task lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling +- **[Error handling](/dist/docs/3.0.13/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery diff --git a/dist/docs/3.0.13/community/joining-slack.mdx b/dist/docs/3.0.13/community/joining-slack.mdx new file mode 100644 index 0000000000..7ab0816eb3 --- /dev/null +++ b/dist/docs/3.0.13/community/joining-slack.mdx @@ -0,0 +1,40 @@ +--- +title: Joining the Community Slack +description: "How to join the AgenticAdvertising.org Slack community — public invite link, domain allowlist policy, and what to do if the link doesn't work." +"og:title": "AdCP — Joining the Community Slack" +--- + +The AdCP community Slack is where protocol development happens — working group discussions, implementation questions, and real-time collaboration across publishers, agencies, and developers. + +## Join with the public invite + +**[→ Join the AdCP Community on Slack](https://join.slack.com/t/agenticads/shared_invite/zt-3h15gj6c0-FRTrD_y4HqmeXDKBl2TDEA)** + +The invite link is public. Click it and follow the Slack prompts to join. + +## If the link doesn't work + +The Slack workspace has a domain allowlist. If your email uses Gmail, a personal email address, or a domain not yet on the allowlist, Slack will silently decline the invite — the link appears to work, but you won't receive an email or get access. + +**This is not a broken link.** It's a domain restriction. + +### What to do + +1. **Ask Addie directly** — open the chat at [agenticadvertising.org/chat](https://agenticadvertising.org/chat) and say "I'm trying to join the Slack but the invite link didn't work." Addie will ask for your email and escalate it to the admin team for a direct invite. + +2. **Email the team** — send a note to [community@agenticadvertising.org](mailto:community@agenticadvertising.org) with your email address and a line about your role. The team typically processes direct invites within one business day. + +## Domain allowlist policy + + +The current allowlist policy is under review. A decision is pending on whether to drop the allowlist in favor of post-join moderation, or to keep it with a lightweight self-serve invite request form. This page will be updated when the decision is made. + + +Currently, the allowlist covers company and organizational domains associated with known publishers, agencies, ad tech vendors, and AAO members. Personal email domains (Gmail, Outlook, Yahoo, etc.) require a direct invite. + +If you represent an organization whose domain isn't on the allowlist, the fastest path is to ask Addie or email the team. Allowlist additions are typically processed within one business day. + +## See also + +- [Working Group](/dist/docs/3.0.13/community/working-group) — what the community works on and how to participate +- [Membership](/dist/docs/3.0.13/aao/users) — join AgenticAdvertising.org for full access (working groups, certification, Slack) diff --git a/dist/docs/3.0.13/community/working-group.mdx b/dist/docs/3.0.13/community/working-group.mdx new file mode 100644 index 0000000000..c820155443 --- /dev/null +++ b/dist/docs/3.0.13/community/working-group.mdx @@ -0,0 +1,44 @@ +--- +title: Working Group +description: "AdCP working group: join the open community of platforms, agencies, and developers shaping the Ad Context Protocol. Collaborate on Slack and GitHub." +"og:title": "AdCP — Working Group" +--- + + +The working group is where AgenticAdvertising.org's mission happens in practice — builders and thinkers developing agentic solutions that pair the scale of AI with the power of human judgment. + +The Ad Context Protocol Working Group is an open community of platform providers, advertisers, agencies, and developers working together to shape the future of AI-powered advertising. + +## Join the Discussion + +Our primary collaboration happens through Slack: + +**[→ Join the AdCP Community on Slack](https://join.slack.com/t/agenticads/shared_invite/zt-3h15gj6c0-FRTrD_y4HqmeXDKBl2TDEA)** + +## What We Discuss + +- **Protocol Development**: Propose and discuss new features and improvements +- **Implementation Questions**: Get help implementing AdCP in your platform +- **Use Cases**: Share how you're using AdCP in real-world scenarios +- **Best Practices**: Learn from others' experiences and share your own +- **Future Direction**: Help shape the roadmap for AdCP + +## How to Participate + +1. **Start a Discussion**: Share ideas, ask questions, or propose changes +2. **Join Conversations**: Comment on existing discussions +3. **Share Experiences**: Tell us about your implementation journey +4. **Help Others**: Answer questions and share your expertise + +## Stay Updated + +- **Join Slack Channels**: Participate in topic-specific discussions +- **Follow Announcements**: Important updates are posted in the #announcements channel +- **Star the Project**: Show your support on GitHub and stay connected + +## Other Ways to Connect + +- **Email**: For private inquiries, reach out to hello@adcontextprotocol.org +- **GitHub Issues**: Report bugs or request features in the [issue tracker](https://github.com/adcontextprotocol/adcp/issues) + +We look forward to collaborating with you! \ No newline at end of file diff --git a/dist/docs/3.0.13/contributing/storyboard-authoring.md b/dist/docs/3.0.13/contributing/storyboard-authoring.md new file mode 100644 index 0000000000..a1a5b94514 --- /dev/null +++ b/dist/docs/3.0.13/contributing/storyboard-authoring.md @@ -0,0 +1,324 @@ +--- +title: Storyboard authoring +description: "How to author AdCP compliance storyboards: the canonical account shape, session scoping lint, sync_plans plan-level identity, and cross-tenant probe opt-out." +"og:title": "AdCP — Storyboard authoring" +--- + +# Storyboard authoring — scoping rules + +Compliance storyboards live under `static/compliance/source/`. Each step that invokes a training-agent task that scopes session state by tenant **must** carry brand or account identity in `sample_request`. Otherwise the call lands in `open:default`, and a follow-up step that *does* carry identity writes to `open:` — giving you `MEDIA_BUY_NOT_FOUND` against your own just-created media buy. + +This rule is enforced at build time by `scripts/lint-storyboard-scoping.cjs`, which runs as part of `npm run build:compliance`. + +## Canonical identity shape + +Use `account { brand, operator }`. The `AccountRef` schema requires `operator` whenever the natural-key form (`brand`) is used — there is no "just a brand" shape at the spec level. + +```yaml +sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + # ... +``` + +Explicit-account form (when the seller issued an `account_id` via `list_accounts`): + +```yaml +sample_request: + account: + account_id: "acc_acme_001" + # ... +``` + +For `sync_plans`, identity lives inside each plan entry. The `sync-plans-request` schema defines `brand` on each plan item and forbids `account` there — do not use the wrapper form inside `plans[]`: + +```yaml +sample_request: + plans: + - plan_id: "plan-001" + brand: + domain: "acmeoutdoor.example" + # ... +``` + +## What about top-level `brand`? + +Some AdCP requests (`create_media_buy`, `get_products`, `build_creative`) have a top-level `brand` field. That is **the campaign's brand**, a separate schema field — not an identity shorthand. `create_media_buy` requires both `account` and `brand`; one does not substitute for the other. + +The lint still accepts a bare top-level `brand.domain` as a fallback because the training agent's `sessionKeyFromArgs` reads it — but that is a training-agent routing detail, not a spec-canonical shape. New storyboards should use `account { brand, operator }`. + +## Which tasks are session-scoped? + +The authoritative list lives in `scripts/lint-storyboard-scoping.cjs` as `TENANT_SCOPED_TASKS`. A parity test (`tests/lint-storyboard-scoping.test.cjs`) asserts every task registered in the training agent's `HANDLER_MAP` appears in either `TENANT_SCOPED_TASKS` or `EXEMPT_FROM_LINT`. If you add a new tool to the dispatch table and forget to classify it, the parity test fails — you won't get silent drift. + +Rule of thumb: if the task's **request schema has a required globally-unique scope-ID** (`plan_id`, `rights_id`, `standards_id`, `list_id`, `event_source_id`), the seller can resolve the tenant from that ID alone — envelope identity is redundant and the lint does not require it (see `EXEMPT_FROM_LINT` bucket (c)). + +Everything else falls into `TENANT_SCOPED_TASKS`: create/update mutations without a scope-ID, list/get operations that don't carry a single resource ID, resource-standards calls without `standards_id` in schema, etc. These must carry envelope `account { brand, operator }`. + +## Identity fields that flow through `$context` + +When a step captures a value into `$context` via `context_outputs` and a later step consumes it as `$context.`, the *entity type* at both ends must match. If the value captured from a field annotated `advertiser_brand` is consumed as a field annotated `rights_holder_brand`, the lint will flag it (that's the #2627 bug: same field name, different entity). See `docs/contributing/x-entity-annotation.md` for the list of entity types and how schema authors annotate fields. + +Other exempt categories: payload-array-keyed sync tasks (`sync_accounts`, `sync_governance`, `sync_catalogs`, `sync_event_sources`), global discovery (`list_creative_formats`, `get_adcp_capabilities`), global catalog reads (`get_brand_identity`, `get_rights`, `update_rights`), and the `comply_test_controller` sandbox primitive. + +### Why ID-scoped tasks are exempt but storyboards still carry identity + +`check_governance`, `report_plan_outcome`, `acquire_rights`, `log_event`, `calibrate_content`, `validate_content_delivery`, and `validate_property_delivery` all require a globally-unique ID (`plan_id`, `rights_id`, `standards_id`, etc.) that was previously provisioned with brand context. At the spec level, a real seller resolves the ID → tenant via their own lookup; the envelope doesn't need to repeat the identity. + +The training agent's `sessionKeyFromArgs` routes by envelope identity. A storyboard that **drops** identity on an ID-scoped task lands in `open:default` and fails to find the plan/rights/standards — so storyboards carry envelope identity anyway, and the lint just won't enforce it. + +This is a sandbox routing convention, not a spec claim. Production sellers resolve tenant from the authenticated principal (bearer/OAuth/HMAC), not from envelope payload — see [Tenant resolution](/dist/docs/3.0.13/building/by-layer/L2/authentication#tenant-resolution). They don't need envelope identity on ID-scoped tasks and wouldn't rely on it if present. Building a cross-session reverse index in the training agent just to move identity off the wire would be sandbox plumbing without spec meaning. + +## Intentionally cross-tenant probes + +If your step is *supposed* to probe a session-scoped task without tenant identity — e.g. a negative test that verifies the seller rejects the bare request, or a capability-discovery probe — annotate the step: + +```yaml +- id: probe_without_brand + task: get_media_buys + scoping: global + sample_request: + # ... no brand/account here by design +``` + +Use sparingly. When in doubt, carry brand identity — nearly all real-world calls do. + +## Fixtures and cross-step captures + +Storyboards that need prerequisite state (a product with a specific `product_id`, a creative already in `approved` status, a plan the governance flow can reference) have two ways to set it up: **declarative `fixtures:` at the storyboard root** for state that exists *before* the test runs, and **step `context_outputs:` captures** for IDs *generated during* the run. + +### When to use which + +| Fixture origin | Pattern | Authored as | +|---|---|---| +| Exists before the storyboard (needs seeding) | `fixtures:` at storyboard root | Declarative block; runner seeds via `comply_test_controller` `seed_*` | +| Generated by an earlier step in this run | `context_outputs:` on the generating step, `$context.` on later steps | Captured at runtime; stays inside this run | +| Runner-supplied (webhook URLs, etc.) | `{{runner.webhook_url:}}` | Substitution variable | + +**Never hardcode a literal ID in `sample_request` if you can avoid it.** A literal like `media_buy_id: "mb_acme_q2_2026_auction"` only works if the agent happens to generate (or accept) that exact ID. Spec-compliant agents auto-generate IDs — the literal won't match and your storyboard will fail for an implementer who did nothing wrong. + +### Pattern A — prerequisite fixtures via `fixtures:` + `comply_test_controller` + +Declare fixtures at the storyboard root. Set `prerequisites.controller_seeding: true` to tell the runner to auto-inject a fixtures phase before the main phases. + +```yaml +id: sales_non_guaranteed +prerequisites: + controller_seeding: true + description: "Requires a seeded product and approved creative." + +fixtures: + products: + - product_id: "test-product" + delivery_type: "non_guaranteed" + pricing_options: + - pricing_option_id: "test-pricing" + pricing_model: "cpm" + currency: "USD" + creatives: + - creative_id: "campaign_hero_video" + status: "approved" + format_id: { id: "video_30s" } + +phases: + - id: place_buy + steps: + - id: create_buy + task: create_media_buy + sample_request: + packages: + - product_id: "test-product" # ← seeded above + pricing_option_id: "test-pricing" # ← seeded above +``` + +The runner injects a fixtures phase that calls `comply_test_controller` with `scenario: seed_product`, `scenario: seed_pricing_option`, and `scenario: seed_creative` (in foreign-key order) before running `place_buy`. An agent that implements the seed scenarios passes out of the box; an agent that returns `UNKNOWN_SCENARIO` on the seeds causes the storyboard to grade as `not_applicable`, not failed — implementers don't get penalized for missing sandbox-only surface. + +See the full list of seed scenarios and their params in [Compliance test controller — Scenarios](/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller#scenarios). + +### Pattern B — flow-derived captures via `context_outputs:` + `$context.` + +Capture the ID the generating step returned, then reference it by `$context.` on downstream steps. + +```yaml +steps: + - id: create_buy + task: create_media_buy + sample_request: + packages: [...] + context_outputs: + - name: media_buy_id + path: "media_buy_id" # JSON path against this step's response + + - id: check_buy + task: get_media_buys + sample_request: + media_buy_ids: ["$context.media_buy_id"] # ← resolved at run time +``` + +The runner captures `media_buy_id` from `create_buy`'s response (after its validations pass), stores it in the run-scoped context accumulator, then substitutes the literal string `$context.media_buy_id` in `check_buy.sample_request` before sending. Agents see the actual ID — never the literal `$context.foo` token. + +Capture failures grade the *generating* step, not the reader: if the response doesn't contain `media_buy_id` at the declared path, `create_buy` fails with `capture_path_not_resolvable`. This is deliberate — the contract the storyboard declared ("this step produces a `media_buy_id`") is what failed, not the step that tried to use it. + +### Context block and the echo contract + +Storyboards that assert on response `context` MUST send a `context:` block on the sample_request: + +```yaml +sample_request: + packages: [...] + context: + correlation_id: "sales_non_guaranteed--create_buy" +validations: + - check: field_value + path: "context.correlation_id" + value: "sales_non_guaranteed--create_buy" + description: "Agent echoes context verbatim" +``` + +The runner does NOT auto-inject `context:` on sample_requests that omit it. Storyboards whose validator expects `context.correlation_id` in the response but whose sample_request lacks `context:` are authoring bugs — the agent is allowed (and required) to omit context when the caller sent none. + +See [Context and sessions — Normative echo contract](/dist/docs/3.0.13/building/by-layer/L2/context-sessions#normative-echo-contract) for the agent-side rules. + +## Asserting on errors + +AdCP surfaces errors in two layers (see [Error handling — envelope vs. payload](/dist/docs/3.0.13/building/by-layer/L3/error-handling#envelope-vs-payload-errors-the-two-layer-model)). Storyboards MUST assert error shape in a way that works regardless of which layer a conformant agent surfaced the error on. + +**Use `check: error_code` — not `check: field_present, path: "errors"`.** + +```yaml +# ✅ Shape-agnostic — resolves from either adcp_error (envelope) or errors[] (payload) +validations: + - check: error_code + value: "BUDGET_TOO_LOW" + description: "Budget validation rejected with BUDGET_TOO_LOW" + +# ✅ Multiple acceptable codes +validations: + - check: error_code + allowed_values: ["VALIDATION_ERROR", "INVALID_REQUEST", "BUDGET_TOO_LOW"] + +# ❌ Pins to the payload `errors[]` shape — fails against agents that surface +# errors only via the transport envelope (MCP `adcp_error`, A2A DataPart) +validations: + - check: field_present + path: "errors" +``` + +Every code used in `value:` or `allowed_values:` MUST exist in the canonical error-code enum at `static/schemas/source/enums/error-code.json`. The `lint:error-codes` script (wired into `npm run test`) walks every storyboard and rejects references to codes that aren't in the enum — a build failure before any test runs. + +When a rename is required, register the old code in `scripts/error-code-aliases.json`. The file is pure data (it lives next to the lint script that reads it, not in the schema tree) and ships with an empty `aliases` map by default: + +```json +{ + "aliases": { + "OLD_CODE": "NEW_CODE" + } +} +``` + +Aliased codes pass the lint as **warnings** during the deprecation window, giving authors time to migrate storyboards. Once the alias is removed from the file, references to the old code become lint errors. This is how renames land without breaking storyboard authorship across versions. + +## Asserting on branchable behaviors + +Some spec requirements allow multiple conformant agent behaviors — e.g. a past `start_time` on `create_media_buy` MAY be rejected with `INVALID_REQUEST` OR accepted-and-adjusted forward. A single-assertion validator that asserts only one branch forces a conformant agent that picked the other branch to silently fail. + +When the spec allows a branchable outcome, split the storyboard into parallel optional phases and resolve via `assert_contribution`: + +```yaml +phases: + - id: reject_path + optional: true + steps: + - id: probe_reject + expect_error: true + contributes_to: behavior_handled + validations: + - check: error_code + value: "INVALID_REQUEST" + + - id: adjust_path + optional: true + steps: + - id: probe_adjust + contributes_to: behavior_handled + validations: + - check: response_schema + - check: field_present + path: "media_buy_id" + + - id: enforcement + steps: + - id: require_either + task: assert_contribution + validations: + - check: any_of + allowed_values: ["behavior_handled"] + description: "Agent must exhibit one of the conformant branches." +``` + +Failures inside an `optional: true` phase do NOT fail the storyboard — only the synthetic `assert_contribution` in the final phase does, and only when no branch contributed. Conformant agents pass exactly one branch and fail the other by design. + +The non-chosen branch's failing steps MUST be reported by the runner with skip reason `peer_branch_taken`, not `failed`. This keeps runner summaries accurate for conformant agents (the other-branch failures were not real failures) and keeps dashboard coverage signals clean (`peer_branch_taken` is runtime routing; `not_applicable` is for protocol coverage gaps). See `universal/storyboard-schema.yaml` § "Per-step grading in any_of branch patterns" and `universal/runner-output-contract.yaml` > `skip_result.reasons.peer_branch_taken` for the normative rule. + +Canonical example: `past_start_reject_path` / `past_start_adjust_path` / `past_start_enforcement` in `universal/schema-validation.yaml`. Use the same shape for any spec `MAY` / `any_of` where observable outcomes differ across branches. + +Single-code `check: error_code` is still correct when the spec mandates a canonical code for a scenario (e.g. `GOVERNANCE_DENIED` on a governance-denied outcome, `NOT_CANCELLABLE` on re-cancel). The split-phase pattern applies only when the spec itself leaves the outcome branchable. + +### When NOT to use this pattern + +The parallel-optional-phases + `assert_contribution` shape is only appropriate when the **spec text itself** permits multiple observable outcomes (look for explicit `MAY`/`OR` in the normative prose, or an enum of acceptable statuses). It is **not** a tool for softening a vector because an agent's behavior drifted from the spec. Do not apply this pattern to: + +- **Idempotency semantics.** `idempotency_key` must be rejected when missing on mutating tasks; replay must return the cached response; conflict must surface `IDEMPOTENCY_CONFLICT`. The spec mandates single behaviors — any other outcome is non-conformant, not a valid branch. +- **Context echo.** Responses MUST echo `context:` verbatim when the caller sent it. There is no conformant branch that omits the echo. +- **Error-code vocabulary.** Canonical codes enumerated in `static/schemas/source/enums/error-code.json` are single-value per scenario. If a storyboard asserts `GOVERNANCE_DENIED` on a governance-denied outcome, that is the code — not one option among several. +- **Webhook signing correctness.** RFC 9421 signing with AdCP's covered-components profile is a single verification shape; there is no alternate branch. + +If you find yourself reaching for the split-phase pattern to get past a failing vector, first verify the spec actually permits the branch you want to accept. If it doesn't, the fix is in the agent (or in the spec), not in the vector. + +## Adding a catalog-substitution-safety phase to a new specialism + +If you are adding a specialism that renders catalog-item macros into URLs +(catalog-driven sales, generative sellers, retail-media, etc.), your storyboard +SHOULD include a substitution-safety phase covering the rule set at +[`docs/creative/universal-macros.mdx#substitution-safety-catalog-item-macros`](../creative/universal-macros.mdx#substitution-safety-catalog-item-macros). + +**Start from the template, don't copy-paste from a sibling specialism.** The +canonical three-step phase (`sync_*_probe_catalog` → `build_*_probe_creative` +→ `expect_substitution_safe`) lives as a `phase_template:` comment block in +[`static/compliance/source/test-kits/substitution-observer-runner.yaml`](../../../../static/compliance/source/test-kits/substitution-observer-runner.yaml). +The block uses `<>` tokens for the specialism-specific bits +(brand domain, catalog_id prefix, idempotency prefix) so you can materialize a +new phase by doing a simple text substitution against those tokens. + +Copying a near-clone from `sales-catalog-driven` or `creative-generative` +works in principle, but the DX reviewer on [#2654](https://github.com/adcontextprotocol/adcp/issues/2654) +flagged that three consumers is the inflection point where trivial drift +starts (misspelled `item_id`, missing `require_every_binding_observed: true`). +The template is the drift-avoidance surface; the `lint:substitution-vector-names` +script ([#2655](https://github.com/adcontextprotocol/adcp/issues/2655)) +catches typos in the vector_name references. + +## Running the lint locally + +```bash +npm run build:compliance # includes the lint +node scripts/lint-storyboard-scoping.cjs # lint only +npm run test:storyboard-scoping # parity test +``` + +Typical failure output: + +``` +✗ storyboard scoping lint: 1 violation(s) + + protocols/media-buy/scenarios/invalid_transitions.yaml:setup/create_buy (create_media_buy) — sample_request missing brand/account + +Fix: add `account { brand, operator }` to sample_request, e.g. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" +``` diff --git a/dist/docs/3.0.13/contributing/testable-examples-demo.md b/dist/docs/3.0.13/contributing/testable-examples-demo.md new file mode 100644 index 0000000000..723eb90ddb --- /dev/null +++ b/dist/docs/3.0.13/contributing/testable-examples-demo.md @@ -0,0 +1,87 @@ +--- +title: Testable examples demo +description: "AdCP testable documentation demo: validated JSON schemas and executable JavaScript code blocks that run against a live test agent." +"og:title": "AdCP — Testable examples demo" +testable: true +--- + +# Testable Documentation Examples + +This page demonstrates the testable documentation feature with complete, working code examples that execute against the live test agent. + +## JavaScript Example + +### List Creative Formats + +```javascript +import { testAgent } from '@adcp/client/testing'; + +const result = await testAgent.listCreativeFormats({}); + +console.log(`✓ Found ${result.data?.formats?.length || 0} creative formats`); +``` + +## Python Example + +### List Creative Formats + +```python +import asyncio +from adcp.testing import test_agent + +async def list_formats(): + result = await test_agent.simple.list_creative_formats() + print(f"✓ Found {len(result.formats)} supported creative formats") + +asyncio.run(list_formats()) +``` + +## CLI Example + +### Using uvx (Python CLI) + +```bash +uvx adcp \ + https://test-agent.adcontextprotocol.org/mcp \ + list_creative_formats \ + '{}' \ + --auth $ADCP_AUTH_TOKEN +``` + +## How Testable Documentation Works + +When `testable: true` is set in the frontmatter, ALL code blocks on this page are extracted and executed during testing. + +### Running Tests + +```bash +# Run all tests including snippet validation +npm run test:all +``` + +### Requirements for Testable Pages + +Every code block must: +- Be complete and self-contained +- Import all required dependencies +- Execute without errors +- Produce output confirming success + +### When to Mark Pages as Testable + +Mark a page `testable: true` ONLY when: +- ALL code blocks are complete working examples +- No code fragments or incomplete snippets +- All examples use test agent credentials +- Dependencies are installed (`@adcp/client`, `adcp`) + +### When NOT to Mark Pages as Testable + +Do NOT mark pages testable that contain: +- Code fragments showing patterns +- Incomplete examples +- Conceptual pseudocode +- Examples requiring production credentials +- Mixed testable and non-testable content + +See [Testable Snippets Guide](./testable-snippets.md) for complete documentation. diff --git a/dist/docs/3.0.13/contributing/testable-snippets.md b/dist/docs/3.0.13/contributing/testable-snippets.md new file mode 100644 index 0000000000..23f8e674ff --- /dev/null +++ b/dist/docs/3.0.13/contributing/testable-snippets.md @@ -0,0 +1,339 @@ +--- +title: Testable snippets +description: "How to write testable AdCP documentation: frontmatter flags, JSON schema validation, executable code blocks, and CI checks for keeping examples accurate." +"og:title": "AdCP — Testable snippets" +--- + +# Writing Testable Documentation Snippets + +This guide explains how to write code examples in AdCP documentation that are automatically tested for correctness. + +## Why Test Documentation Snippets? + +Automated testing of documentation examples ensures: +- Examples stay up-to-date with the latest API +- Code snippets actually work as shown +- Breaking changes are caught immediately +- Users can trust the documentation + +**Important**: The test infrastructure validates code blocks **directly in the documentation files** (`.md` and `.mdx`). When you mark a page with `testable: true` in the frontmatter, ALL code blocks on that page are extracted and executed. + +## Marking Pages for Testing + +To mark an entire page as testable, add `testable: true` to the frontmatter: + +```markdown +--- +title: get_products +testable: true +--- + +# get_products + +...all code examples here will be tested... +``` + +**Key principle**: Pages should be EITHER fully testable OR not testable at all. We don't support partially testable pages (mixing testable and non-testable code blocks on the same page). + +### Example Code Blocks + +Once a page is marked `testable: true`, all code blocks are executed: + +````markdown +```javascript +import { testAgent } from '@adcp/client/testing'; + +const products = await testAgent.getProducts({ + brief: 'Premium athletic footwear with innovative cushioning', + brand: { + domain: 'nike.com' + } +}); + +console.log(`Found ${products.products.length} products`); +``` +```` + +### Using Test Helpers + +For simpler examples, use the built-in test helpers from client libraries: + +**JavaScript:** +```javascript +import { testAgent, testAgentNoAuth } from '@adcp/client/testing'; + +// Authenticated access +const fullCatalog = await testAgent.getProducts({ + brief: 'Premium CTV inventory' +}); + +// Unauthenticated access +const publicCatalog = await testAgentNoAuth.getProducts({ + brief: 'Premium CTV inventory' +}); +``` + +**Python:** +```python +import asyncio +from adcp.testing import test_agent, test_agent_no_auth + +async def example(): + # Authenticated access + full_catalog = await test_agent.simple.get_products( + brief='Premium CTV inventory' + ) + + # Unauthenticated access + public_catalog = await test_agent_no_auth.simple.get_products( + brief='Premium CTV inventory' + ) + +asyncio.run(example()) +``` + +## Best Practices + +### 1. Use Test Agent Credentials + +Always use the public test agent for examples: + +- **Test Agent URL**: `https://test-agent.adcontextprotocol.org` +- **MCP Token**: Your AAO API key (set as `$ADCP_AUTH_TOKEN`) +- **A2A Token**: `L4UCklW_V_40eTdWuQYF6HD5GWeKkgV8U6xxK-jwNO8` + +### 2. Make Examples Self-Contained + +Each testable snippet should: +- Import all required dependencies +- Initialize connections +- Execute a complete operation +- Produce visible output (console.log, etc.) + +**Good Example:** +```javascript +// Example of a complete, testable snippet +import { AdcpClient } from '@adcp/client'; + +const client = new AdcpClient({ + agentUrl: 'https://test-agent.adcontextprotocol.org/mcp', + protocol: 'mcp', + bearerToken: 'sk_your_api_key_here' +}); + +const products = await client.getProducts({ + brief: 'Nike Air Max 2024' +}); + +console.log('Success:', products.products.length > 0); +``` + +**Bad Example (incomplete — no imports, no client setup, no output):** +```javascript +const products = await client.getProducts({ + brief: 'Premium CTV inventory' +}); +``` + +### 3. Use sandbox accounts + +When demonstrating operations that modify state (create, update, delete), use a sandbox account reference: + +```javascript +// Example using sandbox account — no real campaign created +const mediaBuy = await client.createMediaBuy({ + account: { + brand: { domain: 'acme-corp.com' }, + operator: 'acme-corp.com', + sandbox: true + }, + product_id: 'prod_123', + budget: 10000, + start_date: '2025-11-01', + end_date: '2025-11-30' +}); + +console.log('Sandbox media buy created:', mediaBuy.media_buy_id); +``` + +### 4. Handle Async Operations + +JavaScript/TypeScript examples should use `await` or `.then()`: + +```javascript +// Using await (recommended) +const products = await client.getProducts({...}); + +// Or using .then() +client.getProducts({...}).then(products => { + console.log('Products:', products.products.length); +}); +``` + +### 5. Keep Examples Focused + +Each testable snippet should demonstrate ONE concept: + +```javascript +// Good: Demonstrates authentication +import { AdcpClient } from '@adcp/client'; + +const client = new AdcpClient({ + agentUrl: 'https://test-agent.adcontextprotocol.org/mcp', + protocol: 'mcp', + bearerToken: 'sk_your_api_key_here' +}); + +console.log('Authenticated:', client.isAuthenticated); +``` + +## When NOT to Mark Pages as Testable + +Some documentation pages should NOT have `testable: true`: + +### 1. Pages with Pseudo-code or Conceptual Examples + +If your page includes conceptual examples that aren't meant to execute: + +```javascript +// Conceptual workflow - not actual code +const result = await magicFunction(); // ✗ Not a real function +``` + +### 2. Pages with Incomplete Code Fragments + +Pages showing partial code snippets for illustration: + +```javascript +// Incomplete fragment showing field structure +budget: 10000, +start_date: '2025-11-01' +``` + +### 3. Pages with Configuration/Schema Examples + +Documentation showing JSON schemas or configuration structures: + +```json +{ + "product_id": "example", + "name": "Example Product" +} +``` + +### 4. Pages with Response Examples + +Pages showing example API responses (not requests): + +```json +{ + "products": [ + {"product_id": "prod_123", "name": "Premium Display"} + ] +} +``` + +### 5. Pages with Mixed Testable and Non-Testable Code + +If your page has SOME runnable code but SOME conceptual code, split into separate pages: +- One page marked `testable: true` with complete, runnable examples +- Another page without the flag for conceptual/partial examples + +**Remember**: Every code block on a testable page will be executed. If any block can't run, don't mark the page as testable. + +## Running Snippet Tests + +### Locally + +Test all documentation snippets: + +```bash +npm test +``` + +Or specifically run the snippet tests: + +```bash +node tests/snippet-validation.test.js +``` + +This will: +1. Scan all `.md` and `.mdx` files in `docs/` +2. Find pages with `testable: true` in frontmatter +3. Extract ALL code blocks from those pages +4. Execute each snippet and report results +5. Exit with error if any tests fail + +### In CI/CD + +The full test suite (including snippet tests) can be run with: + +```bash +npm run test:all +``` + +This includes: +- Schema validation +- Example validation +- Snippet validation +- TypeScript type checking + +## Supported Languages + +Currently supported languages for testing: + +- **JavaScript** (`.js`, `javascript`, `js`) +- **TypeScript** (`.ts`, `typescript`, `ts`) - compiled to JS +- **Bash** (`.sh`, `bash`, `shell`) - only `curl` commands +- **Python** (`.py`, `python`) - requires Python 3 installed + +### Limitations + +**Package Dependencies**: Snippets that import external packages (like `@adcp/client` or `adcp`) will only work if: +1. The package is installed in the repository's `node_modules` +2. Or the package is listed in `devDependencies` + +For examples requiring the client library, you have options: +- **Option 1**: Add the library to `devDependencies` so tests can import it +- **Option 2**: Don't mark those snippets as testable; document them as conceptual examples instead +- **Option 3**: Use curl/HTTP examples for testable documentation (no package dependencies) + +## Debugging Failed Tests + +When a snippet test fails: + +1. **Check the error message** - The test output shows which file and line number failed +2. **Run the snippet manually** - Copy the code and run it locally +3. **Verify test agent is accessible** - Check https://test-agent.adcontextprotocol.org +4. **Check dependencies** - Ensure all imports are available +5. **Review the snippet** - Make sure it's self-contained + +Example error output: + +``` +Testing: quickstart.mdx:272 (javascript block #6) + ✗ FAILED + Error: Cannot find module '@adcp/client' +``` + +This indicates the `@adcp/client` package needs to be installed. + +## Contributing Guidelines + +When adding new documentation: + +1. ✅ **DO** mark entire pages as `testable: true` if ALL code blocks are runnable +2. ✅ **DO** use test helpers from client libraries for simpler examples +3. ✅ **DO** test snippets locally before committing (`npm test`) +4. ✅ **DO** keep examples self-contained and complete +5. ✅ **DO** use test agent credentials in examples +6. ❌ **DON'T** mark pages with ANY incomplete fragments as testable +7. ❌ **DON'T** mark pages with pseudo-code as testable +8. ❌ **DON'T** mix testable and non-testable code on the same page +9. ❌ **DON'T** use production credentials in examples + +## Questions? + +- Check existing testable examples in `docs/quickstart.mdx` +- Review the test suite: `tests/snippet-validation.test.js` +- Ask in [Slack Community](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) diff --git a/dist/docs/3.0.13/contributing/x-entity-annotation.md b/dist/docs/3.0.13/contributing/x-entity-annotation.md new file mode 100644 index 0000000000..5a783cdedf --- /dev/null +++ b/dist/docs/3.0.13/contributing/x-entity-annotation.md @@ -0,0 +1,148 @@ +--- +title: x-entity schema annotations +description: "How to annotate AdCP schema fields that carry entity identity, so the cross-storyboard context-entity lint can catch conflation bugs like the #2627 brand_id advertiser-vs-rights-holder case." +"og:title": "AdCP — x-entity schema annotations" +--- + +# `x-entity` schema annotations + +## TL;DR for schema authors + +You're editing a schema and the field you're adding (or reviewing) is an id, slug, or stable reference: + +1. **Does the value ever cross storyboard steps?** (captured via `context_outputs`, consumed as `$context.`, or echoed between request and response.) If no, don't annotate. +2. **Pick the entity type** from the table below. If none fits, read the full registry at `static/schemas/source/core/x-entity-types.json` — if still nothing, PR the registry to add one. +3. **Add `x-entity: `** next to `type` on the leaf property. For `$ref`'d shared types, annotate the shared type, not the use site. For a domain sweep with many known id fields, run `node scripts/add-x-entity-annotations.mjs [--overlay ]` — base map at `scripts/x-entity-field-map.json`, per-domain overlays resolve ambiguous names (`list_id`, `plan_id`, `pricing_option_id`). The script validates all values against the registry before writing, so typos hard-fail. +4. If your field is a pass-through **echo** of a value from the request, annotate it with the **same** entity type on both sides. + +The lint is silent on fields without `x-entity`, so partial rollout is safe. + +## Why this exists + +Some AdCP schemas use a single field name — `brand_id`, `list_id`, `plan_id` — for values that refer to **different kinds of entities** in different contexts. The most-cited example: `brand_id` can mean "the advertiser's brand" (from `get_brand_identity`) or "the rights-holder / talent brand" (inside `get_rights`). Same JSON shape, different entity. Both locally valid. The mismatch only surfaces when a storyboard captures a value of one kind into `$context` and a later step consumes it expecting the other — as tracked in [issue #2627](https://github.com/adcontextprotocol/adcp/issues/2627). + +`x-entity` is a non-validating JSON Schema annotation that tags each identity-bearing field with the *entity type* the value resolves against. The context-entity lint (`scripts/lint-storyboard-context-entity.cjs`) walks storyboard `context_outputs` capture sites and `$context.` consume sites, reads `x-entity` at both ends, and flags mismatches. + +## When to add it + +Add `x-entity` to a field if and only if: + +1. The field's value is an id, slug, or stable reference to a business entity, **and** +2. A storyboard could plausibly capture or consume that value across steps (via `context_outputs` or `$context.`). + +Request fields and response fields both take the annotation. Shared types referenced by `$ref` (e.g., `core/brand-id.json`) carry the annotation once; it applies at every use site. + +**Echo fields** (response fields that pass through a value the client sent in the request) *should* be annotated, with the same entity type as the request side. The lint treats capture and consume symmetrically — an annotated echo catches when a storyboard re-captures it into `$context` and forwards it under a misleading name. + +**Do not** annotate: + +- Transient request-scoped values (`idempotency_key`, `request_id`, `correlation_id`). +- Purely descriptive fields (display names, URLs, free-text). +- Fields that don't cross storyboard step boundaries. +- Enum values (`right_type`, `audience_type`) — those are tags, not entity references. + +## Placement + +On the leaf property definition, next to `type` / `description`: + +```json +{ + "properties": { + "brand_id": { + "type": "string", + "description": "Brand identifier from the agent's roster", + "x-entity": "rights_holder_brand" + } + } +} +``` + +For arrays of entities, annotate the item schema: + +```json +{ + "rights": { + "type": "array", + "items": { + "properties": { + "rights_id": { + "type": "string", + "x-entity": "rights_contract" + } + } + } + } +} +``` + +For shared `$ref` types (e.g., `core/brand-id.json`), annotate the shared type. Every use site inherits the entity type: + +```json +{ + "$id": "/schemas/core/brand-id.json", + "type": "string", + "x-entity": "advertiser_brand" +} +``` + +**Shared-type invariant:** once a shared type carries `x-entity`, every `$ref` to it asserts that entity scope. `core/brand-id.json` is tagged `advertiser_brand`, so a rights-holder / talent-roster brand id cannot reuse that type — create a separate shared type (e.g., `core/rights-holder-brand-id.json`) even if the string shape is identical. The lint treats the shared type as the source of truth; silently re-using it across scopes is the bug we're catching. + +If a shared type is used ambiguously across contexts, *split the type* rather than omitting the annotation — ambiguity is the problem the lint exists to catch. + +### Shared types with `oneOf` / `anyOf` / `allOf` variants + +If a shared type's root is a composite (`oneOf` / `anyOf` / `allOf`) and every branch resolves to the same entity, annotate once at the root — the lint reads root-level `x-entity` before descending into variants, so a whole-object capture (e.g., `$context.signal_id` for `core/signal-id.json`) resolves cleanly without duplicating `x-entity` on each variant. `core/signal-id.json` follows this pattern: root-level `x-entity: signal`, and the variant-local `id` fields are deliberately left un-annotated because the `id` is only unique within its variant's namespace (`data_provider_domain` or `agent_url`). Annotating the inner `id` would make two different-namespace ids look interchangeable to the lint. + +If variants resolve to *different* entities, **split the type**. The registry lint flags root+variant disagreement (`composite_entity_disagreement` rule) because the walker's root-level check wins at the empty path and would silently drop the variant value. + +## Registered entity types + +The authoritative list lives at `static/schemas/source/core/x-entity-types.json`. The lint rejects unknown values — extending the registry is intentional and requires a PR. + +High-level groupings (see the registry for full descriptions). *Categories below are editorial grouping for orientation only; the registry at `static/schemas/source/core/x-entity-types.json` is the authoritative list.* + +| Category | Values | +|---|---| +| Brand & rights | `advertiser_brand`, `rights_holder_brand`, `rights_grant` | +| Account & party | `account`, `operator` | +| Media buy | `media_buy`, `package`, `product`, `product_pricing_option` | +| Creative | `creative`, `creative_format` | +| Data & targeting | `audience`, `signal`, `signal_activation_id`, `event_source` | +| Lists & catalogs | `collection_list`, `property_list`, `catalog`, `property` | +| Plans & governance | `media_plan`, `governance_plan`, `governance_registry_policy`, `governance_inline_policy`, `governance_check`, `content_standards`, `task` | +| Vendor services | `vendor_pricing_option` | +| SI | `si_session`, `offering` | + +**Plan vs. policy vs. check:** `governance_plan` identifies the plan container (answers *"which plan?"*); `governance_registry_policy` / `governance_inline_policy` identify a rule inside or referenced by a plan (*"which rule?"*); `governance_check` identifies a specific evaluation of a plan against its policies (*"which check?"* — round-trips between `check_governance` and `report_plan_outcome`). Pick by the question the captured value answers. + +**Registry vs. inline policy:** Use `governance_registry_policy` when the field holds a globally-unique registry id (e.g., `uk_hfss`, `us_coppa`, `garm:brand_safety:violence`). Use `governance_inline_policy` when the field holds a plan-scoped bespoke id authored via `policy-entry.json`. Every `$ref` to `policy-entry.json` in an AdCP task schema is inline by definition — registry entries are served by a separate out-of-band API. If the field can legitimately hold either at runtime (the two ambiguous sites: `check-governance-response::findings[].policy_id`, `get-plan-audit-logs-response` audit entries, plus reserved `creative/creative-feature-result.json::policy_id` and `core/feature-requirement.json::policy_id`), leave it un-annotated and add a `$comment` starting with `"x-entity deliberately omitted"` — the gap lister recognises that phrase and skips the leaf. + +The registry file is the source of truth. To see every annotated field across the repo: `git grep -l x-entity static/schemas/source`. + +### Adding a new entity type + +When a schema change introduces an id that doesn't fit any registered value: + +1. Add the new value to the `enum` array in `static/schemas/source/core/x-entity-types.json`. +2. Add a one-paragraph definition under `x-entity-definitions` in the same file. Describe what the id identifies, the schemas that use it, and any known caveats (e.g., namespace scope). +3. Add the new value to the category table above, in the most appropriate row. +4. If the new value neighbors an existing one (e.g., plan vs. policy vs. check), add a one-sentence disambiguation under the table. +5. If the value will be applied by the patch script in a future domain sweep, add it to `scripts/x-entity-field-map.json` with the canonical field name → entity value mapping. If the same field name splits by domain (like `plan_id` or `list_id`), use the `__scope_specific__` / `__ambiguous__` sentinels and document the overlay pattern the per-domain PR should supply. + +## How the lint reads annotations + +The cross-storyboard walk (`scripts/lint-storyboard-context-entity.cjs`) runs at `npm run build:compliance` and as `npm run test:storyboard-context-entity`: + +1. For each storyboard step's `context_outputs[].path`, walk the step's `response_schema_ref` to the referenced location and read `x-entity` there. Record `(capture_name → entity_type)`. +2. For each storyboard step's `sample_request` field whose value is `$context.`, walk the step's `schema_ref` (request schema) to the referenced field and read `x-entity` there. Look up the name in the capture table. +3. If both ends have `x-entity` and they don't match, flag a violation. + +The lint is **silent on missing annotations** — partial rollout is safe. Missing annotations are treated as "we don't know what entity this is," not as "these must match." This lets the annotation pass proceed domain by domain without generating false positives. To check which domains have been annotated, run `git grep -l x-entity static/schemas/source`. + +## Related + +- Registry: `static/schemas/source/core/x-entity-types.json` +- Lint: `scripts/lint-storyboard-context-entity.cjs` +- Tests: `tests/lint-storyboard-context-entity.test.cjs` +- Canonical case: [#2627 brand_rights storyboard conflates advertiser brand_id with talent brand_id](https://github.com/adcontextprotocol/adcp/issues/2627) +- Tracking issue: [#2660 Storyboard field-entity-context lint](https://github.com/adcontextprotocol/adcp/issues/2660) diff --git a/dist/docs/3.0.13/creative/accessibility.mdx b/dist/docs/3.0.13/creative/accessibility.mdx new file mode 100644 index 0000000000..c77e58f645 --- /dev/null +++ b/dist/docs/3.0.13/creative/accessibility.mdx @@ -0,0 +1,201 @@ +--- +title: Accessibility +description: "AdCP accessibility support lets formats declare WCAG conformance levels and requires accessible assets like alt text and captions." +"og:title": "AdCP — Accessibility" +--- + + +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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/creative/formats) — Format structure and requirements +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) — Asset specifications and payload schemas +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) — Pairing assets with formats +- [list_creative_formats](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) — Format discovery with filtering diff --git a/dist/docs/3.0.13/creative/ai-creative-overview.mdx b/dist/docs/3.0.13/creative/ai-creative-overview.mdx new file mode 100644 index 0000000000..c2b3044b5c --- /dev/null +++ b/dist/docs/3.0.13/creative/ai-creative-overview.mdx @@ -0,0 +1,104 @@ +--- +title: AI creative for campaign teams +description: "AI creative in AdCP lets campaign teams write a brief and get production-ready ads generated across formats without learning the protocol." +"og:title": "AdCP — AI creative for campaign teams" +--- + +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.0.13/creative) follows a strategist from brief to delivery across CTV, display, and social +- **Technical workflow** -- [Generative creative](/dist/docs/3.0.13/creative/generative-creative) walks through the API step by step +- **Library management** -- [Creative libraries and concepts](/dist/docs/3.0.13/creative/creative-libraries) covers organizing and syncing assets +- **CTV and video** -- [CTV and connected TV](/dist/docs/3.0.13/creative/channels/ctv) covers SSAI/CSAI delivery, companion ads, and VAST tags +- **Multi-agent orchestration** -- [Multi-agent creative orchestration](/dist/docs/3.0.13/creative/multi-agent-orchestration) covers distributing creatives across sellers +- **Brand safety details** -- [Sales agent creative capabilities](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities) explains guardrails and inline creative management +- **Learning** -- The [certification program](/dist/docs/3.0.13/learning/overview) teaches AdCP through interactive modules with Addie diff --git a/dist/docs/3.0.13/creative/asset-types.mdx b/dist/docs/3.0.13/creative/asset-types.mdx new file mode 100644 index 0000000000..565d3b147e --- /dev/null +++ b/dist/docs/3.0.13/creative/asset-types.mdx @@ -0,0 +1,491 @@ +--- +title: Asset Types +description: "AdCP asset types define standardized properties for images, video, text, audio, tags, and tracking URLs used in creative formats." +"og:title": "AdCP — Asset Types" +--- + + +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. + +## 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.0.13/creative/asset-types/index.json) - Links to all payload schemas +- Core Asset Schemas at `/schemas/3.0.13/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 +- **GitHub**: 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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.0.13/creative/universal-macros) - Complete macro reference +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - Manifest structure and validation +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) - Image, HTML, and JavaScript asset specifications diff --git a/dist/docs/3.0.13/creative/channels/dooh.mdx b/dist/docs/3.0.13/creative/channels/dooh.mdx new file mode 100644 index 0000000000..0e0cfb67ac --- /dev/null +++ b/dist/docs/3.0.13/creative/channels/dooh.mdx @@ -0,0 +1,254 @@ +--- +title: DOOH (Digital Out-of-Home) +description: "DOOH ad formats in AdCP define digital billboard, transit screen, and venue display specifications with venue-based impression tracking." +"og:title": "AdCP — DOOH (Digital Out-of-Home)" +--- + + +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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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.0.13/creative/universal-macros) - Complete macro reference including DOOH macros +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - Manifest structure and examples +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) - URL asset specifications diff --git a/dist/docs/3.0.13/creative/channels/print.mdx b/dist/docs/3.0.13/creative/channels/print.mdx new file mode 100644 index 0000000000..d28a82d0fd --- /dev/null +++ b/dist/docs/3.0.13/creative/channels/print.mdx @@ -0,0 +1,436 @@ +--- +title: Print Ads +description: "Print ad formats in AdCP cover newspaper, magazine, and trade publication advertising with physical dimensions, bleed, DPI, and CMYK color space requirements." +"og:title": "AdCP — Print Ads" +--- + +This guide covers how AdCP represents print advertising formats for newspapers, magazines, inserts, and trade publications. + +## How print works in AdCP + +Print uses the same building blocks as every other channel: + +- **Collections** model publications (Vogue, Bergedorfer Zeitung, Ad Age) +- **Installments** model issues (March 2026 issue, Issue #47) +- **Installment deadlines** carry booking, cancellation, and material due dates +- **Creative formats** define physical dimensions, bleed, DPI, and file requirements +- **Placements** define positions (full page, half page, island, inside front cover) + +No print-specific schemas are needed. The standard product model handles print when sellers declare physical units in their formats and deadlines on their installments. + +## Print format characteristics + +Print formats differ from digital in several ways: + +- **Physical units** — dimensions in inches or centimeters, not pixels +- **DPI requirements** — minimum 300 DPI for standard print, 150 DPI for newspaper +- **Bleed** — extra image area beyond the trim that prevents white edges after cutting +- **Color space** — CMYK for full-color print, grayscale for B&W +- **File formats** — press-ready PDF, TIFF, or EPS instead of JPG/PNG + +## Standard print formats + +### Full page (magazine) + +```json +{ + "format_id": { + "agent_url": "https://ads.publisher.example.com", + "id": "full_page" + }, + "name": "Full Page", + "renders": [{ + "role": "primary", + "dimensions": { + "width": 8.375, + "height": 10.875, + "unit": "inches" + } + }], + "assets": [ + { + "item_type": "individual", + "asset_id": "artwork", + "asset_type": "image", + "asset_role": "print_artwork", + "required": true, + "requirements": { + "min_width": 8.375, + "max_width": 8.375, + "min_height": 10.875, + "max_height": 10.875, + "unit": "inches", + "min_dpi": 300, + "bleed": { "uniform": 0.125 }, + "color_space": "cmyk", + "formats": ["pdf", "tiff", "eps"] + }, + "overlays": [ + { "id": "safe_left", "description": "Left trim margin — keep headlines, logos, and CTAs inside", "bounds": { "x": 0, "y": 0, "width": 0.25, "height": 10.875, "unit": "inches" } }, + { "id": "safe_right", "description": "Right trim margin", "bounds": { "x": 8.125, "y": 0, "width": 0.25, "height": 10.875, "unit": "inches" } }, + { "id": "safe_top", "description": "Top trim margin", "bounds": { "x": 0, "y": 0, "width": 8.375, "height": 0.25, "unit": "inches" } }, + { "id": "safe_bottom", "description": "Bottom trim margin", "bounds": { "x": 0, "y": 10.625, "width": 8.375, "height": 0.25, "unit": "inches" } } + ] + } + ] +} +``` + +### Half page portrait (newspaper) + +```json +{ + "format_id": { + "agent_url": "https://ads.publisher.example.com", + "id": "half_page_portrait" + }, + "name": "1/2 Seite Hochformat", + "renders": [{ + "role": "primary", + "dimensions": { + "width": 130, + "height": 185, + "unit": "mm" + } + }], + "assets": [ + { + "item_type": "individual", + "asset_id": "artwork", + "asset_type": "image", + "asset_role": "print_artwork", + "required": true, + "requirements": { + "min_width": 130, + "max_width": 130, + "min_height": 185, + "max_height": 185, + "unit": "mm", + "min_dpi": 150, + "bleed": { "uniform": 3 }, + "color_space": "cmyk", + "formats": ["pdf", "tiff"] + } + } + ] +} +``` + +### Insert/supplement + +Multi-page inserts use a repeatable group for pages: + +```json +{ + "format_id": { + "agent_url": "https://ads.publisher.example.com", + "id": "insert_4page" + }, + "name": "4-Page Insert", + "renders": [{ + "role": "primary", + "dimensions": { + "width": 8.375, + "height": 10.875, + "unit": "inches" + } + }], + "assets": [ + { + "item_type": "repeatable_group", + "asset_group_id": "pages", + "required": true, + "min_count": 4, + "max_count": 4, + "selection_mode": "sequential", + "assets": [ + { + "asset_id": "page", + "asset_type": "image", + "asset_role": "insert_page", + "required": true, + "requirements": { + "min_width": 8.375, + "max_width": 8.375, + "min_height": 10.875, + "max_height": 10.875, + "unit": "inches", + "min_dpi": 300, + "bleed": { "uniform": 0.125 }, + "color_space": "cmyk", + "formats": ["pdf"] + } + } + ] + } + ] +} +``` + +## Publications and issues + +A publication maps to a [collection](/dist/docs/3.0.13/media-buy/product-discovery/collections-and-installments). Each issue maps to an installment with deadlines. + +### The publication (collection) + +```json +{ + "collection_id": "bergedorfer_zeitung", + "name": "Bergedorfer Zeitung", + "kind": "publication", + "description": "Regional daily newspaper for Hamburg-Bergedorf", + "genre": ["IAB12"], + "genre_taxonomy": "iab_content_3.0", + "language": "de", + "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 + } +} +``` + +With `deadline_policy`, the collection declares lead-time rules once. Agents compute absolute deadlines from each installment's `scheduled_at`. A daily newspaper no longer needs to enumerate deadlines for every issue — the policy covers the common case, and individual installments can override when needed (e.g., holiday editions with earlier deadlines). + +### An issue (installment with deadlines) + +```json +{ + "installment_id": "2026-03-28", + "name": "Freitag, 28. März 2026", + "scheduled_at": "2026-03-28T05:00:00+01:00", + "status": "scheduled", + "deadlines": { + "booking_deadline": "2026-03-24T17:00:00+01:00", + "cancellation_deadline": "2026-03-25T12:00:00+01:00", + "material_deadlines": [ + { + "stage": "draft", + "due_at": "2026-03-25T17:00:00+01:00", + "label": "Entwurf zur Prüfung" + }, + { + "stage": "final", + "due_at": "2026-03-26T17:00:00+01:00", + "label": "Druckfertige PDF mit Beschnitt (3mm)" + } + ] + } +} +``` + +For a monthly magazine with longer lead times: + +```json +{ + "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" + } + ] + } +} +``` + +## Complete product example + +A regional newspaper selling print ad inventory across upcoming issues: + +```json +{ + "products": [ + { + "product_id": "bz_display_april", + "name": "Bergedorfer Zeitung — Display Ads, April 2026", + "channels": ["print"], + "collections": [{ + "publisher_domain": "bergedorfer-zeitung.de", + "collection_ids": ["bergedorfer_zeitung"] + }], + "publisher_properties": [{ + "publisher_domain": "bergedorfer-zeitung.de", + "selection_type": "all" + }], + "installments": [ + { + "installment_id": "2026-04-01", + "name": "Mittwoch, 1. April 2026", + "scheduled_at": "2026-04-01T05:00:00+02:00", + "status": "scheduled", + "deadlines": { + "booking_deadline": "2026-03-26T17:00:00+01:00", + "cancellation_deadline": "2026-03-27T12:00:00+01:00", + "material_deadlines": [ + { "stage": "final", "due_at": "2026-03-28T17:00:00+01:00", "label": "Druckfertige PDF" } + ] + } + }, + { + "installment_id": "2026-04-02", + "name": "Donnerstag, 2. April 2026", + "scheduled_at": "2026-04-02T05:00:00+02:00", + "status": "scheduled", + "deadlines": { + "booking_deadline": "2026-03-27T17:00:00+01:00", + "cancellation_deadline": "2026-03-28T12:00:00+01:00", + "material_deadlines": [ + { "stage": "final", "due_at": "2026-03-29T17:00:00+01:00", "label": "Druckfertige PDF" } + ] + } + } + ], + "placements": [ + { "placement_id": "full_page", "name": "Ganze Seite" }, + { "placement_id": "half_page", "name": "1/2 Seite" }, + { "placement_id": "quarter_page", "name": "1/4 Seite" } + ], + "format_ids": [ + { "agent_url": "https://ads.bergedorfer-zeitung.de", "id": "full_page" }, + { "agent_url": "https://ads.bergedorfer-zeitung.de", "id": "half_page_portrait" }, + { "agent_url": "https://ads.bergedorfer-zeitung.de", "id": "quarter_page" } + ], + "delivery_type": "guaranteed", + "delivery_measurement": { + "provider": "IVW (Informationsgemeinschaft zur Feststellung der Verbreitung von Werbeträgern)", + "notes": "Verified circulation figures, updated quarterly" + }, + "pricing_options": [ + { + "pricing_option_id": "full_page_rate", + "pricing_model": "flat_rate", + "fixed_price": 2400, + "currency": "EUR" + }, + { + "pricing_option_id": "half_page_rate", + "pricing_model": "flat_rate", + "fixed_price": 1350, + "currency": "EUR" + } + ] + } + ] +} +``` + +## Print-specific image requirements + +### Physical dimensions + +Print formats declare dimensions in physical units (`inches` or `cm`) instead of pixels. The `unit` field on both format renders and image asset requirements controls interpretation: + +```json +{ + "dimensions": { + "width": 8.375, + "height": 10.875, + "unit": "inches" + } +} +``` + +When `unit` is absent, dimensions default to pixels (backward compatible with digital formats). + +### Bleed + +Bleed is the extra image area beyond the trim size. After printing, the page is cut to the trim dimensions — bleed ensures ink coverage extends to the edge with no white border. + +Bleed can be specified per-side or uniformly: + +```json +"bleed": { "uniform": 0.125 } +``` + +```json +"bleed": { "top": 0.125, "right": 0.125, "bottom": 0.25, "left": 0.125 } +``` + +Values use the same unit as the parent dimensions. + +### Calculating total image dimensions + +For a full-page magazine ad at 8.375 x 10.875 inches with 0.125" uniform bleed at 300 DPI: + +1. **Total physical size**: (8.375 + 0.125 + 0.125) x (10.875 + 0.125 + 0.125) = 8.625 x 11.125 inches +2. **Pixel dimensions**: 8.625 x 300 = 2588 pixels wide, 11.125 x 300 = 3338 pixels tall +3. **Submitted image**: 2588 x 3338 px, CMYK, PDF or TIFF + +For a European newspaper at 130 x 185 mm with 3 mm uniform bleed at 150 DPI: + +1. **Total physical size**: (130 + 3 + 3) x (185 + 3 + 3) = 136 x 191 mm +2. **Convert to inches**: 136 / 25.4 = 5.354", 191 / 25.4 = 7.520" +3. **Pixel dimensions**: 5.354 x 150 = 803 px wide, 7.520 x 150 = 1128 px tall + +### DPI + +`min_dpi` specifies the minimum dots per inch for acceptable print quality: + +| Use case | Typical min_dpi | +|----------|----------------| +| Magazine (coated stock) | 300 | +| Newspaper (uncoated) | 150 | +| Large format / billboard | 72-150 | + +### Color space + +Print production requires CMYK color separation. Digital images in RGB must be converted before press. The `color_space` field declares what the publisher accepts: + +- `cmyk` — standard for offset and digital print +- `rgb` — accepted when the publisher handles conversion +- `grayscale` — for black-and-white placements + +### File formats + +| Format | Use case | +|--------|----------| +| `pdf` | Press-ready composite (PDF/X-4 recommended) | +| `tiff` | Rasterized artwork, lossless | +| `eps` | Vector artwork with embedded fonts | + +### Safe area (trim margins) + +Print production trims pages to their final size, and the cut can shift slightly. Critical content (headlines, logos, CTAs) placed too close to the trim edge risks being cut off. + +Publishers declare trim margins using the standard [overlay](/dist/docs/3.0.13/creative/formats#overlays) pattern — the same mechanism used for CTV player controls and DOOH bezels. Each overlay marks a zone where creative agents should avoid placing important content: + +```json +"overlays": [ + { "id": "safe_left", "description": "Left trim margin", "bounds": { "x": 0, "y": 0, "width": 6, "height": 185, "unit": "mm" } }, + { "id": "safe_right", "bounds": { "x": 124, "y": 0, "width": 6, "height": 185, "unit": "mm" } }, + { "id": "safe_top", "bounds": { "x": 0, "y": 0, "width": 130, "height": 6, "unit": "mm" } }, + { "id": "safe_bottom", "bounds": { "x": 0, "y": 179, "width": 130, "height": 6, "unit": "mm" } } +] +``` + +A 6mm margin is standard for European newspaper production. Overlay bounds support physical units (`mm`, `cm`, `inches`) alongside `px` and `fraction`, so publishers can express safe areas in whichever unit their prepress workflow uses. + +## Deadlines beyond print + +Installment deadlines are not print-specific. Any channel with advance material requirements uses the same pattern. See [collections and installments](/dist/docs/3.0.13/media-buy/product-discovery/collections-and-installments) for deadlines on podcasts, influencer host reads, and live events. + +## Related documentation + +- [Collections and installments](/dist/docs/3.0.13/media-buy/product-discovery/collections-and-installments) — the collection/installment model and deadlines +- [Creative formats](/dist/docs/3.0.13/creative/formats) — format structure and asset discovery +- [Media channel taxonomy](/dist/docs/3.0.13/reference/media-channel-taxonomy) — all 20 channels including print diff --git a/dist/docs/3.0.13/creative/channels/social-native.mdx b/dist/docs/3.0.13/creative/channels/social-native.mdx new file mode 100644 index 0000000000..57aebc8856 --- /dev/null +++ b/dist/docs/3.0.13/creative/channels/social-native.mdx @@ -0,0 +1,184 @@ +--- +title: Social and feed-native +description: "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." +"og:title": "AdCP — Social and feed-native" +--- + +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 +{ + "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 +{ + "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.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/creative/implementing-creative-agents#pattern-4-feed-nativesocial-format-agent) — Implementation guide for feed-native format agents +- [Carousels](/dist/docs/3.0.13/creative/channels/carousels) — Multi-card format specifications +- [Creative review](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities#creative-review) — Approval flow including community guidelines +- [Generative creative](/dist/docs/3.0.13/creative/generative-creative) — AI-powered creative generation workflows diff --git a/dist/docs/3.0.13/creative/channels/video.mdx b/dist/docs/3.0.13/creative/channels/video.mdx new file mode 100644 index 0000000000..023bfcdf4c --- /dev/null +++ b/dist/docs/3.0.13/creative/channels/video.mdx @@ -0,0 +1,887 @@ +--- +title: Video Ads +description: "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." +"og:title": "AdCP — Video 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 +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_15s" + }, + "name": "Standard Video - 15 seconds", + "type": "video", + "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 +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_30s" + }, + "name": "Standard Video - 30 seconds", + "type": "video", + "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 +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_6s" + }, + "name": "6-Second Bumper", + "type": "video", + "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 +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_vertical_15s" + }, + "name": "Vertical Video - 15 seconds", + "type": "video", + "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 +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_30s_ctv" + }, + "name": "CTV Video - 30 seconds", + "type": "video", + "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", + "color_space": "rec709", + "hdr_format": "sdr", + "chroma_subsampling": ["4:2:0"], + "video_bit_depth": [8], + "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"], + "audio_bit_depth": [16, 24], + "audio_bitrate_kbps_min": 192, + "loudness_lufs": -24, + "loudness_tolerance_db": 2, + "true_peak_dbfs": -2 + } + } + ] +} +``` + +#### Platform-Specific CTV Examples + +Different CTV platforms have varying requirements. Sales agents should define formats matching their specific platform needs. + +**Roku-Compliant Format:** +```json +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://sales.example.com", + "id": "video_30s_roku" + }, + "name": "Roku CTV - 30 seconds", + "type": "video", + "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"], + "audio_bit_depth": [16, 24], + "audio_bitrate_kbps_min": 192, + "loudness_lufs": -23, + "loudness_tolerance_db": 2 + } + } + ] +} +``` + +**Hulu-Compliant Format:** +```json +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://sales.example.com", + "id": "video_30s_hulu" + }, + "name": "Hulu CTV - 30 seconds", + "type": "video", + "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", + "chroma_subsampling": ["4:2:0", "4:2:2"], + "audio_codecs": ["pcm", "aac"], + "audio_sample_rates": [48000], + "audio_channels": ["stereo"], + "audio_bit_depth": [16, 24], + "audio_bitrate_kbps_min": 192, + "audio_bitrate_kbps_max": 256, + "loudness_lufs": -24, + "loudness_tolerance_db": 2 + } + } + ] +} +``` + +**YouTube CTV Format:** +```json +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://sales.example.com", + "id": "video_30s_youtube_ctv" + }, + "name": "YouTube CTV - 30 seconds", + "type": "video", + "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"], + "audio_bitrate_kbps_min": 128, + "loudness_lufs": -14 + } + } + ] +} +``` + +#### SSAI-Ready CTV Format + +For server-side ad insertion (SSAI) compatibility, GOP structure is critical: + +```json +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://sales.example.com", + "id": "video_30s_ssai" + }, + "name": "SSAI Video - 30 seconds", + "type": "video", + "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 +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_30s_vast" + }, + "name": "VAST Tag - 30 seconds", + "type": "video", + "assets": [ + { + "asset_id": "vast_tag", + "asset_type": "url", + "asset_role": "vast_url", + "item_type": "individual", + "required": true, + "requirements": { + "vast_version": ["3.0", "4.0", "4.1", "4.2"] + } + } + ] +} +``` + +### VPAID Interactive Video + +```json +{ + "$schema": "/schemas/3.0.13/core/format.json", + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_30s_vpaid" + }, + "name": "VPAID Interactive - 30 seconds", + "type": "video", + "assets": [ + { + "asset_id": "vpaid_tag", + "asset_type": "url", + "asset_role": "vpaid_url", + "item_type": "individual", + "required": true, + "requirements": { + "vpaid_version": ["2.0"], + "api_framework": "VPAID" + } + } + ] +} +``` + +## Creative Manifests + +### Hosted Video Manifest + +```json +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "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 +{ + "$schema": "/schemas/3.0.13/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": "url", + "asset_role": "vpaid_url", + "item_type": "individual", + "required": true, + "requirements": { + "vpaid_version": ["2.0"], + "api_framework": "VPAID" + } + } + ] +} +``` + +## 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.0.13/creative/universal-macros) - Complete macro reference including video macros +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - Manifest structure and asset specifications +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) - Video asset type definitions diff --git a/dist/docs/3.0.13/creative/creative-libraries.mdx b/dist/docs/3.0.13/creative/creative-libraries.mdx new file mode 100644 index 0000000000..820dd457c4 --- /dev/null +++ b/dist/docs/3.0.13/creative/creative-libraries.mdx @@ -0,0 +1,341 @@ +--- +title: Creative libraries and concepts +description: "Creative libraries in AdCP let buyers browse, organize, and track approval status for creatives across ad servers and management platforms." +"og:title": "AdCP — Creative libraries and concepts" +--- + + +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 with [inline creative management](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities). + +## The model + +A creative library organizes assets at three levels: + +| Level | AdCP equivalent | Examples | +|---|---|---| +| Account | Account (via [accounts protocol](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/get_media_buys) after a creative-state change to detect assignment-level impact. See [creative review](/dist/docs/3.0.13/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. Retention of unassigned creatives is seller-defined in 3.0 — buyers should not assume indefinite persistence and should confirm availability via [`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives) before reuse on a new buy. + +## Connecting to a library + +Establish account access before querying: + +```json +{ + "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.0.13/accounts/overview) for details. + +## Browsing creatives + +Use [`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives) to browse the library. Filter by concept, format, status, tags, or date range: + +```json +{ + "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 +{ + "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.0.13/creative/sales-agent-creative-capabilities#creative-review) for how status transitions work. + +## Uploading creatives + +Use [`sync_creatives`](/dist/docs/3.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/creative/task-reference/build_creative) with `creative_id` instead of a manifest: + +```json +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "packages": [{ + "product_id": "premium_display", + "creatives": [{ + "creative_id": "acme_banner_001", + "name": "Holiday banner", + "format_id": { "agent_url": "...", "id": "display_300x250" }, + "assets": { "...": "..." } + }] + }] +} +``` + +The agent adds the creative to its library and assigns it to the package in one operation. See [inline creative management](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities) for details. + +**Inline creatives follow the same library lifecycle as `sync_creatives` uploads.** 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.0.13/media-buy/specification#media-buy-state-transitions) rule. + +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`; it does **not** tie the inline creative's lifecycle to the buy. The decoupled library lifecycle applies regardless of submission path. + +### Multi-seller distribution + +When you work with multiple sellers, build creatives once and distribute them: + +1. **Build** on your creative agent: +```json +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/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 +{ + "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.0.13/creative/generative-creative) — AI-powered creative generation and brief-in-media-buy workflows +- [Creative capabilities on sales agents](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities) — When the seller manages both media and creative +- [Implementing creative agents](/dist/docs/3.0.13/creative/implementing-creative-agents) — Building an AdCP creative agent around your platform +- [sync_creatives reference](/dist/docs/3.0.13/creative/task-reference/sync_creatives) — Upload API details +- [list_creatives reference](/dist/docs/3.0.13/creative/task-reference/list_creatives) — Query API details with full filtering options diff --git a/dist/docs/3.0.13/creative/creative-manifests.mdx b/dist/docs/3.0.13/creative/creative-manifests.mdx new file mode 100644 index 0000000000..29327cf94a --- /dev/null +++ b/dist/docs/3.0.13/creative/creative-manifests.mdx @@ -0,0 +1,471 @@ +--- +title: Creative Manifests +description: "Creative manifests in AdCP are reusable presets that supply concrete asset values to satisfy format requirements for programmatic ad delivery." +"og:title": "AdCP — Creative Manifests" +"og:image": /images/walkthrough/diagram-format-manifest-render.png +--- + + +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.0.13/creative). + + +## Manifest Structure + +### Basic Structure + +```typescript +{ + 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 +{ + "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 +{ + "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 +{ + "$schema": "/schemas/3.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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.0.13/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.0.13/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 +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/creative/channels/carousels) guide for complete documentation on repeatable asset groups. + +## Schema Reference + +- [Creative Manifest Schema](https://adcontextprotocol.org/schemas/3.0.13/core/creative-manifest.json) +- [Preview Creative Request](https://adcontextprotocol.org/schemas/3.0.13/creative/preview-creative-request.json) +- [Preview Creative Response](https://adcontextprotocol.org/schemas/3.0.13/creative/preview-creative-response.json) + +## Related Documentation + +- [Creative Formats](/dist/docs/3.0.13/creative/formats) - Understanding format specifications and discovery +- [Channel Guides](/dist/docs/3.0.13/creative/channels/video) - Format examples across video, display, audio, DOOH, and carousels +- [build_creative Task](/dist/docs/3.0.13/creative/task-reference/build_creative) +- [preview_creative Task](/dist/docs/3.0.13/creative/task-reference/preview_creative) diff --git a/dist/docs/3.0.13/creative/formats.mdx b/dist/docs/3.0.13/creative/formats.mdx new file mode 100644 index 0000000000..dbe61c3f7c --- /dev/null +++ b/dist/docs/3.0.13/creative/formats.mdx @@ -0,0 +1,790 @@ +--- +title: Creative Formats +description: "Creative formats in AdCP define asset requirements, technical constraints, and rendering behavior that shape how ads are built and delivered." +"og:title": "AdCP — Creative Formats" +--- + + +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.0.13/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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/creative/list-creative-formats-response.json", + "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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 + +**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 +{ + "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.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "role": "primary", + "dimensions": { + "width": 300, + "height": 250, + "responsive": {"width": false, "height": false}, + "unit": "px" + } +} +``` + +**Responsive width** (fluid banners): +```json +{ + "role": "primary", + "dimensions": { + "min_width": 300, + "max_width": 970, + "height": 250, + "responsive": {"width": true, "height": false}, + "unit": "px" + } +} +``` + +**Aspect ratio constrained** (native formats): +```json +{ + "role": "primary", + "dimensions": { + "aspect_ratio": "16:9", + "min_width": 300, + "responsive": {"width": true, "height": true}, + "unit": "px" + } +} +``` + +**Physical dimensions** (DOOH): +```json +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 +{ + "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 +{ + "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.0.13/creative) - How formats, manifests, and agents work together +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - Pairing assets with formats +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) - Understanding asset specifications +- [Channel Guides](/dist/docs/3.0.13/creative/channels/video) - Detailed format documentation by media type +- [Implementing Standard Format Support](/dist/docs/3.0.13/media-buy/capability-discovery/implementing-standard-formats) - For sales agents +- [list_creative_formats Task](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) - API reference for format discovery diff --git a/dist/docs/3.0.13/creative/generative-creative.mdx b/dist/docs/3.0.13/creative/generative-creative.mdx new file mode 100644 index 0000000000..d6bcabd797 --- /dev/null +++ b/dist/docs/3.0.13/creative/generative-creative.mdx @@ -0,0 +1,602 @@ +--- +title: Generative Creative +description: "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." +"og:title": "AdCP — Generative Creative" +"og:image": /images/walkthrough/diagram-generative-tiers.png +--- + +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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/brand-protocol/brand-json) for comprehensive examples. + +### Using Your Own Assets + +Provide existing assets to incorporate into the creative: + +```json +{ + "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 +{ + "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 +{ + "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.0.13/media-buy/task-reference/create_media_buy) for the full request structure): + +```json +{ + "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.0.13/creative/sales-agent-creative-capabilities), creatives travel with the media buy. For other workflows, use [`sync_creatives`](/dist/docs/3.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/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.0.13/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 +{ + "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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/creative/task-reference/get_creative_delivery). + +See [Previewing generative creative](/dist/docs/3.0.13/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.0.13/creative/task-reference/build_creative#multi-format-generation) +- **Browse examples**: See [Task reference](/dist/docs/3.0.13/creative/task-reference/build_creative) for `build_creative` examples +- **Sales agent creative flow**: See [Creative capabilities on sales agents](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities) for seller-side generation +- **Delivery reporting**: See [get_creative_delivery](/dist/docs/3.0.13/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.0.13/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.0.13/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! \ No newline at end of file diff --git a/dist/docs/3.0.13/creative/implementing-creative-agents.mdx b/dist/docs/3.0.13/creative/implementing-creative-agents.mdx new file mode 100644 index 0000000000..37b10e4b0a --- /dev/null +++ b/dist/docs/3.0.13/creative/implementing-creative-agents.mdx @@ -0,0 +1,680 @@ +--- +title: Implementing Creative Agents +description: "How to build an AdCP creative agent that defines formats, validates manifests, generates previews, and hosts a creative library." +"og:title": "AdCP — Implementing Creative Agents" +--- + + +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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 +{ + "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 +- [ ] All format_ids use proper namespacing (`domain:id`) +- [ ] Domain in format_id matches your `agent_url` domain +- [ ] `list_creative_formats` returns all your formats +- [ ] `preview_creative` validates manifests and generates previews +- [ ] Format definitions include complete asset requirements +- [ ] Documentation available for your custom formats + +## Integration patterns + +### Pattern 1: creative agency + +You're a creative agency building custom formats for brands: + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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.0.13/creative/specification#interaction-models) in the spec. + +Platforms with a creative library should also implement the [accounts protocol](/dist/docs/3.0.13/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.0.13/creative/formats) - Understanding format structure +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - How manifests work +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) - Asset specifications +- [list_creative_formats task](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) - Format discovery API reference +- [list_creatives task](/dist/docs/3.0.13/creative/task-reference/list_creatives) - Creative library API reference +- [build_creative task](/dist/docs/3.0.13/creative/task-reference/build_creative) - Manifest generation API reference +- [preview_creative task](/dist/docs/3.0.13/creative/task-reference/preview_creative) - Preview rendering API reference diff --git a/dist/docs/3.0.13/creative/index.mdx b/dist/docs/3.0.13/creative/index.mdx new file mode 100644 index 0000000000..5694ecee5d --- /dev/null +++ b/dist/docs/3.0.13/creative/index.mdx @@ -0,0 +1,179 @@ +--- +title: Creative protocol +sidebarTitle: Overview +"og:image": /images/walkthrough/panel-01-strategist-desk.png +description: "The AdCP Creative Protocol manages ad creative from brief to delivery across CTV, display, and social using AI agents and standardized formats." +"og:title": "AdCP — Creative protocol" +--- + +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 +{ + "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 +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.0.13/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.0.13/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 +{ + "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 +{ "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 +{ + "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.0.13/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.0.13/creative/key-concepts) — the building blocks behind this walkthrough +- **AI creative**: [AI creative for campaign teams](/dist/docs/3.0.13/creative/ai-creative-overview) — the strategic view for non-engineers +- **Generative creative**: [Generative creative](/dist/docs/3.0.13/creative/generative-creative) — Tier 1, 2, and 3 in depth +- **Build an agent**: [Implementing creative agents](/dist/docs/3.0.13/creative/implementing-creative-agents) — make your platform speak AdCP +- **Orchestrate**: [Multi-agent orchestration](/dist/docs/3.0.13/creative/multi-agent-orchestration) — the engineering patterns behind Maya's platform +- **Get certified**: The [Buyer track](/dist/docs/3.0.13/learning/tracks/buyer) teaches the full creative workflow through interactive modules diff --git a/dist/docs/3.0.13/creative/key-concepts.mdx b/dist/docs/3.0.13/creative/key-concepts.mdx new file mode 100644 index 0000000000..7f0c6408b2 --- /dev/null +++ b/dist/docs/3.0.13/creative/key-concepts.mdx @@ -0,0 +1,197 @@ +--- +title: Key concepts +description: "Assets, formats, manifests, and creative agents are the four building blocks of the AdCP Creative Protocol for programmatic ad creative." +"og:title": "AdCP — Creative key concepts" +--- + + +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/creative/task-reference/list_creative_formats) - API documentation for creative tasks +- [Generative Creative](/dist/docs/3.0.13/creative/generative-creative) - AI-powered creative generation guide diff --git a/dist/docs/3.0.13/creative/multi-agent-orchestration.mdx b/dist/docs/3.0.13/creative/multi-agent-orchestration.mdx new file mode 100644 index 0000000000..1a7617297a --- /dev/null +++ b/dist/docs/3.0.13/creative/multi-agent-orchestration.mdx @@ -0,0 +1,346 @@ +--- +title: Multi-agent creative orchestration +description: "Multi-agent creative orchestration in AdCP routes requests across agents, distributes assets to sellers, and aggregates delivery data." +"og:title": "AdCP — Multi-agent creative orchestration" +"og:image": /images/walkthrough/diagram-orchestrator-sequence.png +--- + +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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/building/operating/orchestrator-design) — State machines, persistence, and retry patterns for multi-agent systems +- [Creative libraries and concepts](/dist/docs/3.0.13/creative/creative-libraries) — Managing creatives in a single agent's library +- [Creative capabilities on sales agents](/dist/docs/3.0.13/creative/sales-agent-creative-capabilities) — When the seller manages both media and creative +- [Generative creative](/dist/docs/3.0.13/creative/generative-creative) — AI-powered creative generation workflows +- [Specification](/dist/docs/3.0.13/creative/specification) — Full Creative Protocol specification and interaction models diff --git a/dist/docs/3.0.13/creative/private-assets.mdx b/dist/docs/3.0.13/creative/private-assets.mdx new file mode 100644 index 0000000000..7728047e79 --- /dev/null +++ b/dist/docs/3.0.13/creative/private-assets.mdx @@ -0,0 +1,209 @@ +--- +title: Private assets +description: "Private assets in AdCP use presigned URLs to grant temporary access to files stored in DAMs, S3 buckets, or authenticated sources." +"og:title": "AdCP — Private assets" +--- + + +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.0.13/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 +{ + "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 +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 +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 +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 +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.0.13/creative/task-reference/build_creative) only | 1 hour | +| [`build_creative`](/dist/docs/3.0.13/creative/task-reference/build_creative) + [`preview_creative`](/dist/docs/3.0.13/creative/task-reference/preview_creative) | 2 hours | +| Full pipeline (build, preview, iterate, [`sync_creatives`](/dist/docs/3.0.13/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 +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 +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.0.13/creative/creative-manifests) — manifest structure and asset references +- [Asset types](/dist/docs/3.0.13/creative/asset-types) — requirements for each asset type +- [`build_creative`](/dist/docs/3.0.13/creative/task-reference/build_creative) — generating creatives from manifests +- [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) — syncing creatives to an agent-hosted creative library diff --git a/dist/docs/3.0.13/creative/provenance.mdx b/dist/docs/3.0.13/creative/provenance.mdx new file mode 100644 index 0000000000..bd2ceda3ec --- /dev/null +++ b/dist/docs/3.0.13/creative/provenance.mdx @@ -0,0 +1,530 @@ +--- +title: AI provenance and disclosure +description: "AdCP provenance metadata declares AI involvement, tools used, and regulatory disclosure obligations on creative assets and manifests." +"og:title": "AdCP — AI provenance and disclosure" +sidebarTitle: Provenance +--- + +Provenance metadata declares how creative content was produced — whether AI was involved, which tools were used, and what disclosure obligations apply. As regulations like the EU AI Act and California SB 942 require machine-readable AI disclosure in advertising, AdCP carries this metadata at the protocol level so every party in the supply chain can declare, transmit, and verify it using the same structure. 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's provenance metadata provides the structured, machine-readable disclosure that these regulations require — carried through the programmatic supply chain where no standard for it previously existed. + + +## 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.0.13/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 Content Credentials reference (`manifest_url` required) | +| `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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/core/provenance.json", + "digital_source_type": "digital_capture" +} +``` + +### Full example + +```json +{ + "$schema": "/schemas/3.0.13/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.0.13/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. + +## 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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +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 +{ + "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.0.13/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. Pinnacle's system confirms the claim, reads the render guidance for the serving jurisdiction, applies the required 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 +{ + "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 +{ + "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. + +## Creative policy enforcement + +Sellers can require provenance on submitted creatives through the `provenance_required` field in `creative-policy`: + +```json +{ + "$schema": "/schemas/3.0.13/core/creative-policy.json", + "co_branding": "optional", + "landing_page": "any", + "templates_available": false, + "provenance_required": true +} +``` + +When `provenance_required` is `true`: + +1. Buyers must attach provenance to creative submissions +2. The seller may independently verify claims via `get_creative_features` +3. Creatives without provenance are rejected + +This field is surfaced in product discovery through `get_products`, so buyers know the requirement before submitting creatives. + +## 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.0.13/core/provenance.json) | +| Digital source type enum | [`/schemas/enums/digital-source-type.json`](https://adcontextprotocol.org/schemas/3.0.13/enums/digital-source-type.json) | +| Creative asset (with provenance) | [`/schemas/core/creative-asset.json`](https://adcontextprotocol.org/schemas/3.0.13/core/creative-asset.json) | +| Creative manifest (with provenance) | [`/schemas/core/creative-manifest.json`](https://adcontextprotocol.org/schemas/3.0.13/core/creative-manifest.json) | +| Creative policy (provenance_required) | [`/schemas/core/creative-policy.json`](https://adcontextprotocol.org/schemas/3.0.13/core/creative-policy.json) | +| Artifact (with provenance) | [`/schemas/content-standards/artifact.json`](https://adcontextprotocol.org/schemas/3.0.13/content-standards/artifact.json) | + +## Related + +- [Provenance verification](/dist/docs/3.0.13/governance/creative/provenance-verification) -- How the governance infrastructure verifies AI provenance claims +- [Creative Governance](/dist/docs/3.0.13/governance/creative/index) -- Feature-based creative evaluation via `get_creative_features` +- [Content Standards](/dist/docs/3.0.13/governance/content-standards/index) -- Privacy-preserving brand suitability for publisher content +- [Generative Creative](/dist/docs/3.0.13/creative/generative-creative) -- AI-powered creative generation with `build_creative` diff --git a/dist/docs/3.0.13/creative/sales-agent-creative-capabilities.mdx b/dist/docs/3.0.13/creative/sales-agent-creative-capabilities.mdx new file mode 100644 index 0000000000..00af61f482 --- /dev/null +++ b/dist/docs/3.0.13/creative/sales-agent-creative-capabilities.mdx @@ -0,0 +1,316 @@ +--- +title: Creative capabilities on sales agents +sidebarTitle: Sales agent creatives +description: "Sales agents in AdCP implement the Creative Protocol alongside Media Buy for inline creative management and generative ad formats." +"og:title": "AdCP — Creative capabilities on sales agents" +--- + +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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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) and Creative Protocol support are independent. A sales agent can support one, the other, or both. + +`inline_creative_management` allows attaching creatives directly to packages in `create_media_buy` — the creative travels with the buy order. Creative Protocol support (`"creative"` in `supported_protocols`) means the agent implements creative tasks like `build_creative`, `list_creative_formats`, and [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives). A sales agent might support inline creative management without implementing the Creative Protocol (the buyer manages creatives externally and just references them in the buy), or implement the Creative Protocol 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. + +## 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.0.13/accounts/overview) for media buys do not need additional account setup for creative tasks — the same account covers both protocols. + +## 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**: The `status` field on each creative in [`list_creatives`](/dist/docs/3.0.13/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.0.13/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 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +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'); + +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); + } + } +} +``` + +## Related + +- [Creative Specification](/dist/docs/3.0.13/creative/specification) — Protocol requirements for creative agents +- [Implementing Creative Agents](/dist/docs/3.0.13/creative/implementing-creative-agents) — Implementation guide for standalone creative agents +- [Generative Creative](/dist/docs/3.0.13/creative/generative-creative) — Using `build_creative` for AI-powered generation +- [get_creative_delivery](/dist/docs/3.0.13/creative/task-reference/get_creative_delivery) — Variant-level delivery reporting +- [get_media_buy_delivery](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) — Media buy delivery reporting diff --git a/dist/docs/3.0.13/creative/specification.mdx b/dist/docs/3.0.13/creative/specification.mdx new file mode 100644 index 0000000000..17ab34d818 --- /dev/null +++ b/dist/docs/3.0.13/creative/specification.mdx @@ -0,0 +1,477 @@ +--- +title: Creative Specification +description: "The AdCP Creative Protocol specification defines format discovery, manifest validation, AI creative generation, and preview rendering." +"og:title": "AdCP — Creative Specification" +sidebarTitle: Specification +--- + + +**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**: 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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.0.13/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.0.13/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 +{ + "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.0.13/enums/creative-status.json) + +Creatives in a library progress through a defined set of states. `archived` is the only buyer-initiated state change; all others are seller-initiated (processing, review, approval/rejection). + +``` +sync_creatives ──▶ processing ──▶ pending_review ──▶ approved + │ │ + │ (processing failure) │ (buyer archives) + ▼ ▼ + rejected ◀── (policy failure) ── pending_review + │ archived + │ (buyer fixes + resubmits │ + │ via sync_creatives) │ (buyer unarchives) + ▼ ▼ + processing approved +``` + +**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` → `archived`: buyer-initiated via `sync_creatives` +- `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 SHOULD notify the buyer 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 include `status` and `rejection_reason` (when rejected) in `list_creatives` responses. + +## 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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 + +### build_creative + +**Reference**: [`build_creative` task](/dist/docs/3.0.13/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.0.13/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 include `expires_at` for 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.0.13/creative/list-creatives-request.json) / [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creatives-response.json) + +**Reference**: [`list_creatives` task](/dist/docs/3.0.13/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.0.13/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.0.13/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 + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} +**Schema**: [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-request.json) / [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-response.json) + +**Reference**: [`sync_creatives` task](/dist/docs/3.0.13/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.0.13/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.0.13/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 +- `ASSET_MISSING`: Required asset not provided in manifest +- `ASSET_INVALID`: Asset does not meet format constraints +- `GENERATION_FAILED`: Creative generation could not be completed + +## 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`) +- 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 +{ + "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.0.13/core/format.json) | Format definition | +| [`core/creative-manifest.json`](https://adcontextprotocol.org/schemas/3.0.13/core/creative-manifest.json) | Creative manifest | +| [`core/creative-asset.json`](https://adcontextprotocol.org/schemas/3.0.13/core/creative-asset.json) | Asset definition | +| [`media-buy/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/list-creative-formats-request.json) | list_creative_formats request | +| [`media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/list-creative-formats-response.json) | list_creative_formats response | +| [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creatives-request.json) | list_creatives request | +| [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creatives-response.json) | list_creatives response | +| [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-request.json) | sync_creatives request | +| [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-response.json) | sync_creatives response | diff --git a/dist/docs/3.0.13/creative/sponsored-placement-adapter-contracts.mdx b/dist/docs/3.0.13/creative/sponsored-placement-adapter-contracts.mdx new file mode 100644 index 0000000000..26329756c6 --- /dev/null +++ b/dist/docs/3.0.13/creative/sponsored-placement-adapter-contracts.mdx @@ -0,0 +1,77 @@ +--- +title: Sponsored Placement adapter contracts +description: "Four runtime contract families that ship under the single sponsored_placement canonical — Amazon SP, Criteo/CitrusAd, Pinterest/Snap Collection, generative-per-SKU." +"og:title": "AdCP — Sponsored Placement adapter contracts" +--- + +The `sponsored_placement` canonical covers retail-media catalog-driven creative — Amazon SP, Criteo SP, CitrusAd SP, Pinterest Collection, generative-per-SKU. The schema captures the binary axes (`composition_model`, `fanout_mode`, `item_production_model`) but the runtime contract — fan-out semantics, catalog-asset wiring, per-adopter tracking — differs enough between adopter families that a manifest validating against `sponsored_placement` tells the buyer little about whether it will actually serve on a given retail-media network. + +This page documents the four contract families, what runtime status each is `preview`-ready in, and the adopter-specific quirks that don't live in the schema. The canonical stays `preview` past 3.1 GA until at least two retail-media networks ship `format_schema` evidence (see [`canonical-formats.mdx` §`experimental` — one field, both axes](/dist/docs/3.0.13/creative/canonical-formats#experimental--one-field-both-axes)). + +## The four contract families + +| Family | `composition_model` | `fanout_mode` | `item_production_model` | Adopters | +|---|---|---|---|---| +| **Retail-media deterministic, buyer-uploaded** | `deterministic` | `per_item` | `buyer_pre_rendered` | Amazon SP | +| **Retail-media network-composed** | `deterministic` | `per_item` | `seller_pre_rendered_from_brief` | Criteo SP, CitrusAd SP | +| **Collection-layout per impression** | `deterministic` (debatable) | `multi_item_in_creative` | `buyer_pre_rendered` | Pinterest Collection, Snap Collection | +| **Generative-per-SKU** | algorithmic in practice | `per_item` | `seller_pre_rendered_from_brief` | Vendor-specific (Pencil, Persado, vertical retail-media platforms) | + +### 1. Retail-media deterministic, buyer-uploaded (Amazon SP) + +The buyer uploads the catalog assets (product image, headline, description) via `sync_creatives`. The retail-media network ingests the catalog row + assets and serves them deterministically against query/category triggers — no fan-out at render time. The buyer can predict per-impression rendering. + +- **Catalog-asset contract:** buyer supplies one creative per catalog item; the `source_catalog` slot points at the buyer's product feed; per-item assets are required. +- **Tracking:** Amazon ASIN + standard retail-media event vocabulary. Per-item attribution maps to the catalog row's identifier. +- **Adopter quirks:** Amazon's category-trigger system means the same uploaded creative may serve under different category contexts — buyers MUST treat impression-level category as untrusted-by-creative-shape; rely on Amazon's reporting for category attribution. +- **`runtime_status` readiness:** `preview` is appropriate. Most predictable family; `format_schema` evidence from Amazon would promote toward `stable`. + +### 2. Retail-media network-composed (Criteo SP, CitrusAd SP) + +The buyer supplies a brief + brand assets (logo, brand colors); the retail-media network composes the placement against its own catalog + design templates. The buyer cannot predict per-impression rendering at the asset-bundle level, only at the brand-direction level. + +- **Catalog-asset contract:** buyer supplies brief + brand assets; the `source_catalog` is the *seller's* catalog (the retail-media network's product index), not the buyer's. The buyer's product references are matched to seller-catalog rows via SKU / GTIN / category. +- **Tracking:** retail-media network's own event vocabulary; buyer attribution maps through the network's reporting API, not direct creative events. +- **Adopter quirks:** Criteo's composition logic varies by retailer integration — the same buyer brief produces different placements on Walmart vs. Target vs. Best Buy via Criteo. Buyers MUST treat per-retailer rendering as opaque and rely on Criteo's aggregated reporting. +- **`runtime_status` readiness:** `preview` is appropriate. The composition opacity is the gap to `stable` promotion — `format_schema` evidence would need to document the per-retailer variability. + +### 3. Collection-layout per impression (Pinterest Collection, Snap Collection) + +The buyer supplies a hero image + a collection of catalog items; the surface picks the collection layout per impression (grid, carousel, cover + reveal). `composition_model: deterministic` is technically true at the asset level but loose at the layout level. + +- **Catalog-asset contract:** buyer supplies one creative with a hero asset slot + an items array (each item: image + caption + URL). The `source_catalog` slot is the buyer's curated item collection, not a full product feed. +- **Tracking:** Pinterest's collection-item events (`collection_item_click`, `collection_close_up`) plus standard impression/click. Per-item attribution available; layout-variant attribution is not exposed. +- **Adopter quirks:** Snap Collection's "cover + reveal" layout requires a specific aspect ratio and asset count combination — schema doesn't enforce; buyers SHOULD pre-validate before sync. +- **`runtime_status` readiness:** `preview` is appropriate. The "deterministic" claim survives at the asset level but layout variability blocks `stable` without per-surface conformance evidence. + +### 4. Generative-per-SKU (vendor-specific) + +The buyer supplies a brief + catalog reference; the seller's generative pipeline produces a unique creative per SKU. 1 brief × N catalog items → N rendered creatives. `composition_model` is effectively algorithmic — each output is a fresh generative composition against the brief. + +- **Catalog-asset contract:** buyer supplies the brief + a catalog scope (SKUs, categories, or full feed); the seller produces creatives via `build_creative` with `item_production_model: seller_pre_rendered_from_brief` and `fanout_mode: per_item`. The `source_catalog` slot is the buyer's product feed; assets are seller-generated. +- **Tracking:** generative-output ID + buyer's catalog-item key (SKU/GTIN). Buyers SHOULD treat each generated creative as a distinct rendered output with its own performance signal, not as a copy of the brief. +- **Adopter quirks:** generative seeds and prompts are typically not exposed back to the buyer; buyers cannot reproduce a specific generation. Plan for re-generation cycles rather than asset-edit cycles. +- **`runtime_status` readiness:** `declared_only` until generative-quality evidence is documented per-adopter. `preview` is misleading — the composition is opaque to the buyer in a way `preview` doesn't capture. + +## `runtime_status` interaction matrix + +| Family | `preview` | `declared_only` | `stable` | +|---|---|---|---| +| Retail-media deterministic, buyer-uploaded | ✅ ready | n/a | gated on Amazon `format_schema` evidence | +| Retail-media network-composed | ✅ ready | n/a | gated on per-retailer variability documentation | +| Collection-layout per impression | ✅ ready | n/a | gated on per-surface conformance evidence | +| Generative-per-SKU | ⚠ misleading | ✅ default | gated on generative-quality evidence; may not promote without per-vendor framing | + +`runtime_status` lives on the product (not the creative); see [`canonical-formats.mdx` §runtime status](/dist/docs/3.0.13/creative/canonical-formats) for the field-level contract. + +## What this page is not + +- **Not a normative spec extension.** The four families share the existing `sponsored_placement` schema; this doc names the variability sellers and buyers will encounter against the canonical, but adds no required fields. +- **Not a promotion gate.** The canonical's promotion to `stable` is gated on `format_schema` evidence across at least two adopters, per [canonical-formats.mdx](/dist/docs/3.0.13/creative/canonical-formats). This page documents what `format_schema` evidence will need to cover for each family. +- **Not exhaustive.** Adopters whose runtime contract doesn't fit one of the four families above SHOULD file an issue against the canonical-formats track so the matrix can be extended. + +## Refs + +- [#3307](https://github.com/adcontextprotocol/adcp/pull/3307) — original `sponsored_placement` canonical +- [#4592](https://github.com/adcontextprotocol/adcp/issues/4592) — this doc +- [`canonical-formats.mdx`](/dist/docs/3.0.13/creative/canonical-formats) — overall canonical-formats vocabulary diff --git a/dist/docs/3.0.13/creative/task-reference/build_creative.mdx b/dist/docs/3.0.13/creative/task-reference/build_creative.mdx new file mode 100644 index 0000000000..c8b7f59ab1 --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/build_creative.mdx @@ -0,0 +1,1270 @@ +--- +title: build_creative +description: "build_creative generates, transforms, or retrieves ad creative manifests in AdCP from a natural language brief to production-ready assets." +"og:title": "AdCP — build_creative" +--- + + +Transform, generate, or retrieve a creative manifest for a specific format. 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.0.13/creative/task-reference/list_creatives)) and the agent resolves it from its library. + +For information about format IDs and how to reference formats, see [Creative Formats - Referencing Formats](/dist/docs/3.0.13/creative/formats#referencing-formats). + +## 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.0.13/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. 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. 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 when generating. Caps generation cost for catalog-driven formats. | +| `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.0.13/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. | + +### 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.0.13/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. + +**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. + +### 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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "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.0.13/creative/task-reference/list_creatives) and want the creative agent to produce a delivery-ready manifest with tags (HTML, JavaScript, or VAST): + +```json +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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. + + +**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.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "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. + + +### 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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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.0.13/creative/task-reference/list_creatives#pricing). The buyer passes it in [`report_usage`](/dist/docs/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/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": { /* ... */ } + } + ] +} +``` + +### 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. 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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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.0.13/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.0.13/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 +{ + "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.0.13/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 +// 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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/build-creative-response.json", + "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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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` is not supported by this creative agent | +| `INVALID_MANIFEST` | 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) | diff --git a/dist/docs/3.0.13/creative/task-reference/get_creative_delivery.mdx b/dist/docs/3.0.13/creative/task-reference/get_creative_delivery.mdx new file mode 100644 index 0000000000..63ab7e35b9 --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/get_creative_delivery.mdx @@ -0,0 +1,400 @@ +--- +title: get_creative_delivery +description: "get_creative_delivery retrieves variant-level delivery data with manifests and performance metrics for generative and static creatives in AdCP." +"og:title": "AdCP — get_creative_delivery" +--- + + +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.0.13/creative/sales-agent-creative-capabilities). + +**Request Schema**: [`/schemas/3.0.13/creative/get-creative-delivery-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/get-creative-delivery-request.json) +**Response Schema**: [`/schemas/3.0.13/creative/get-creative-delivery-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 | +| `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) | + +### 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 (impressions, spend, clicks, ctr, etc.) | + +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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +// 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 +{ + "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. diff --git a/dist/docs/3.0.13/creative/task-reference/list_creative_formats.mdx b/dist/docs/3.0.13/creative/task-reference/list_creative_formats.mdx new file mode 100644 index 0000000000..ed80e1a4a7 --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/list_creative_formats.mdx @@ -0,0 +1,681 @@ +--- +title: list_creative_formats +description: "list_creative_formats discovers available ad format specifications from any AdCP agent, including asset requirements and technical constraints." +"og:title": "AdCP — list_creative_formats" +testable: true +--- + + +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.0.13/creative/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creative-formats-request.json) +**Response Schema**: [`/schemas/3.0.13/creative/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/creative/sales-agent-creative-capabilities). + +See [list_creative_formats (Sales Agent)](/dist/docs/3.0.13/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`, `html`, `javascript`, `url`. 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.0.13/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 | 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. | +| `input_format_ids` | FormatID[] | No | 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. | +| `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.0.13/core/format.json) for complete format object structure. + +### Recursive Discovery + +Creative agents may reference other creative agents that provide additional formats: + +```json +{ + "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 +{ + "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.0.13/media-buy/task-reference/sync_catalogs) before submitting creatives. See [Catalogs](/dist/docs/3.0.13/creative/catalogs) for the full lifecycle. + +## Common Scenarios + +### Get Specs for Product Format IDs + + + +```javascript JavaScript +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 + +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { ListCreativeFormatsResponseSchema } from '@adcp/client'; + +// 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 +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` | Creative formats this format accepts as input manifests (omitted for formats that work from raw assets) | +| `output_format_ids` | Output formats this format can produce (omitted for formats that produce a single fixed output) | + +### 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" + +**To find formats requiring specific combinations**, filter results after retrieval: + +```javascript test=false +// 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 +// 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.0.13/creative/task-reference/build_creative) to assemble assets into format +2. **Preview**: Use [`preview_creative`](/dist/docs/3.0.13/creative/task-reference/preview_creative) to see visual output +3. **Validate**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) with `dry_run: true` +4. **Upload**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) to upload to an agent-hosted creative library + +## Learn More + +- [Format Schema](https://adcontextprotocol.org/schemas/3.0.13/core/format.json) - Complete format structure +- [Asset Types](/dist/docs/3.0.13/creative/asset-types) - Asset specification details +- [Standard Formats](/dist/docs/3.0.13/media-buy/capability-discovery/implementing-standard-formats) - IAB-compatible reference formats diff --git a/dist/docs/3.0.13/creative/task-reference/list_creatives.mdx b/dist/docs/3.0.13/creative/task-reference/list_creatives.mdx new file mode 100644 index 0000000000..4dbcb4893a --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/list_creatives.mdx @@ -0,0 +1,338 @@ +--- +title: list_creatives +description: "list_creatives browses and filters creatives in an AdCP library by format, status, concept, and tags with cursor-based pagination." +"og:title": "AdCP — list_creatives" +--- + + +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 + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} +**Schema**: [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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`. | +| `account` | [AccountRef](/dist/docs/3.0.13/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.0.13/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.0.13/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. + + + +## Sorting options + +Sort results by various fields with ascending or descending order: + +```json +{ + "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 +{ + "pagination": { + "max_results": 50, + "cursor": "eyJjcmVhdGVkX2RhdGUiOi4uLn0" + } +} +``` + +## Response format + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} +**Schema**: [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creatives-response.json) + +The response provides creative data with optional enrichment: + +```json +{ + "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.0.13/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 +{ + "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.0.13/signals/tasks/get_signals) and [content standards](/dist/docs/3.0.13/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.0.13/creative/task-reference/get_creative_delivery). + +```json +{ + "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. | + +## Account requirements + + +Creative agents that host a library should implement the [accounts protocol](/dist/docs/3.0.13/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 +{ + "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 +{ + "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 +{ + "filters": { + "has_variables": true, + "statuses": ["approved"] + }, + "include_variables": true +} +``` + +### Field-limited query + +Get minimal creative data for a selection dropdown: + +```json +{ + "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 +{ + "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.0.13/creative/task-reference/get_creative_delivery) - Detailed performance analytics with date ranges, variant breakdowns, and full delivery metrics +- [`build_creative`](/dist/docs/3.0.13/creative/task-reference/build_creative) - Build manifests from library creatives or generate from scratch +- [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) - Upload and manage creative assets on any agent hosting a creative library +- [`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) - Discover supported creative formats +- [`preview_creative`](/dist/docs/3.0.13/creative/task-reference/preview_creative) - Generate previews of creative manifests diff --git a/dist/docs/3.0.13/creative/task-reference/preview_creative-advanced.mdx b/dist/docs/3.0.13/creative/task-reference/preview_creative-advanced.mdx new file mode 100644 index 0000000000..2467bc2dee --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/preview_creative-advanced.mdx @@ -0,0 +1,319 @@ +--- +title: preview_creative (Advanced) +description: "Advanced AdCP preview patterns including format showcase pages, caching strategies, and batch preview workflows for creative agents." +"og:title": "AdCP — preview_creative (Advanced)" +--- + + +Advanced patterns for creative preview integration including workflows, caching strategies, and implementation notes. + +For basic usage, see [preview_creative](/dist/docs/3.0.13/creative/task-reference/preview_creative). + +## Common Workflows + +### Format Showcase Pages + +Build a browsable catalog of available formats: + +```typescript +// 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 +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 + + +
+ + +
+``` + +**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 +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 + +## Error Handling + +```typescript +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 +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 +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 +{ + "inputs": [ + { "name": "Desktop", "macros": { "DEVICE_TYPE": "desktop" } }, + { "name": "Mobile", "macros": { "DEVICE_TYPE": "mobile" } }, + { "name": "CTV", "macros": { "DEVICE_TYPE": "ctv" } } + ] +} +``` + +### Geographic Variants +```json +{ + "inputs": [ + { "name": "NYC", "macros": { "CITY": "New York", "DMA": "501" } }, + { "name": "LA", "macros": { "CITY": "Los Angeles", "DMA": "803" } } + ] +} +``` + +### Privacy Compliance Testing +```json +{ + "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 +{ + "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. Implement security sandboxing +7. Set reasonable expiration (24-48 hours) + +**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.0.13/creative/task-reference/preview_creative) - Basic usage and parameters +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - Manifest structure +- [Universal Macros](/dist/docs/3.0.13/creative/universal-macros) - Available macro values diff --git a/dist/docs/3.0.13/creative/task-reference/preview_creative.mdx b/dist/docs/3.0.13/creative/task-reference/preview_creative.mdx new file mode 100644 index 0000000000..7c81544790 --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/preview_creative.mdx @@ -0,0 +1,424 @@ +--- +title: preview_creative +description: "preview_creative generates visual previews of ad creative manifests in AdCP in single or batch mode returning URL, image, or HTML output." +"og:title": "AdCP — preview_creative" +--- + + +Generate preview renderings of creative manifests. Supports both single creative preview and batch preview (5-10x faster for multiple creatives). + +**Request Schema**: [`/schemas/3.0.13/creative/preview-creative-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/preview-creative-request.json) +**Response Schema**: [`/schemas/3.0.13/creative/preview-creative-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/preview-creative-response.json) + +## Quick Start + +### Single Creative Preview + +```json +{ + "request_type": "single", + "creative_manifest": { /* includes format_id, assets */ } +} +``` + +Response: + +```json +{ + "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 + +``` + +### Direct HTML Embedding + +For faster rendering without iframe overhead, request HTML directly: + +```json +{ + "request_type": "single", + "creative_manifest": { /* includes format_id, assets */ }, + "output_format": "html" +} +``` + +Response contains raw HTML: + +```json +{ + "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 +{ + "request_type": "batch", + "requests": [ + { "creative_manifest": { /* creative 1 */ } }, + { "creative_manifest": { /* creative 2 */ } } + ] +} +``` + +Response contains results in order: + +```json +{ + "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 +{ + "request_type": "variant", + "variant_id": "gen_mobile_morning" +} +``` + +Response: + +```json +{ + "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 +{ + "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 +{ + response_type: "single"; + previews: Preview[]; // One per input (or one default) + interactive_url?: string; // Optional sandbox for interactive formats + expires_at: string; // ISO 8601 expiration +} +``` + +### Batch Mode Response + +```typescript +{ + 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 +{ + 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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "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 + +All previews have an `expires_at` timestamp. After expiration, preview URLs return errors and must be re-generated. For generative creative, re-generating a pre-flight preview may produce different output — the same brief and context can yield different creative each time. + +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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "request_type": "batch", + "output_format": "html", + "requests": [ + { "creative_manifest": { /* creative 1 */ } }, + { "creative_manifest": { /* creative 2 */ } } + ] +} +``` + +### AI-Generated Audio Preview + +```json +{ + "$schema": "/schemas/3.0.13/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 (check `expires_at`) +- Handle partial batch failures by checking each result's `success` field + +## Related Documentation + +- [Advanced Preview Patterns](/dist/docs/3.0.13/creative/task-reference/preview_creative-advanced) - Caching, workflows, implementation notes +- [Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests) - Manifest structure +- [Creative Formats](/dist/docs/3.0.13/creative/formats) - Format specifications diff --git a/dist/docs/3.0.13/creative/task-reference/sync_creatives.mdx b/dist/docs/3.0.13/creative/task-reference/sync_creatives.mdx new file mode 100644 index 0000000000..21b88f7495 --- /dev/null +++ b/dist/docs/3.0.13/creative/task-reference/sync_creatives.mdx @@ -0,0 +1,711 @@ +--- +title: sync_creatives +description: "sync_creatives uploads and manages creative assets in an AdCP library with bulk uploads, upsert semantics, and generative creative support." +"og:title": "AdCP — sync_creatives" +testable: true +--- + + +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) + +{/* Using latest because these schemas are not yet released in any version. + Update to correct version alias after the next release. */} +**Request Schema**: [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-request.json) +**Response Schema**: [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-response.json) + +## Quick start + +Upload creative assets: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncCreativesResponseSchema } from "@adcp/client"; + +const result = await testAgent.syncCreatives({ + creatives: [ + { + creative_id: "creative_video_001", + name: "Summer Sale 30s", + format_id: { + agent_url: "https://creative.adcontextprotocol.org", + id: "video_standard_30s", + }, + assets: { + 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 +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.sync_creatives( + creatives=[{ + 'creative_id': 'creative_video_001', + 'name': 'Summer Sale 30s', + 'format_id': { + 'agent_url': 'https://creative.adcontextprotocol.org', + 'id': 'video_standard_30s' + }, + 'assets': { + '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). + +## Request parameters + +| Parameter | Type | Required | Description | +| ----------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `account` | object | Yes | Account reference identifying the advertiser/workspace for this sync ([account-ref](/dist/docs/3.0.13/accounts/overview)) | +| `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, preview changes without applying them (default: false) | +| `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 | Yes | Format specification (structured object with `agent_url` and `id`) | +| `assets` | object | Yes | Assets keyed by role (e.g., `{video: {...}, thumbnail: {...}}`). Catalogs are included as assets with `asset_type: "catalog"`. See [Catalogs](/dist/docs/3.0.13/creative/catalogs). | +| `tags` | string[] | No | Searchable tags for creative organization | + +### Asset structure + +Assets are keyed by role name. Each role contains the asset details: + +```json test=false +{ + "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 + } + } +} +``` + +### 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 +{ + "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.0.13/enums/creative-status.json)): `processing`, `pending_review`, `approved`, `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`). **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.0.13/creative/sync-creatives-response.json) + +## Common scenarios + +### Bulk upload + +Upload multiple creatives in one call: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncCreativesResponseSchema } from "@adcp/client"; + +const result = await testAgent.syncCreatives({ + creatives: [ + { + creative_id: "creative_display_001", + name: "Summer Sale Banner 300x250", + format_id: { + agent_url: "https://creative.adcontextprotocol.org", + id: "display_300x250", + }, + assets: { + 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: { + 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: { + 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.name}: ${creative.platform_id}`); + }); +} +``` + +```python Python +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.sync_creatives( + creatives=[ + { + 'creative_id': 'creative_display_001', + 'name': 'Summer Sale Banner 300x250', + 'format_id': { + 'agent_url': 'https://creative.adcontextprotocol.org', + 'id': 'display_300x250' + }, + 'assets': { + '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': { + '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': { + '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.name}: {creative.platform_id}") + +asyncio.run(main()) +``` + + + +### Generative creatives + +Use the creative agent to generate creatives from brand identity data. See the [Generative Creatives guide](/dist/docs/3.0.13/creative/generative-creative) for complete workflow details. + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncCreativesResponseSchema } from "@adcp/client"; + +const result = await testAgent.syncCreatives({ + creatives: [ + { + creative_id: "creative_gen_001", + name: "AI-Generated Summer Banner", + format_id: { + agent_url: "https://creative.adcontextprotocol.org", + id: "display_300x250", + }, + assets: { + manifest: { + url: "https://cdn.example.com/brand.json", + }, + }, + }, + ], +}); + +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 +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.sync_creatives( + creatives=[{ + 'creative_id': 'creative_gen_001', + 'name': 'AI-Generated Summer Banner', + 'format_id': { + 'agent_url': 'https://creative.adcontextprotocol.org', + 'id': 'display_300x250' + }, + 'assets': { + 'manifest': { + 'url': 'https://cdn.example.com/brand.json' + } + } + }] + ) + + # 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 +import { testAgent } from "@adcp/client/testing"; +import { SyncCreativesResponseSchema } from "@adcp/client"; + +const result = await testAgent.syncCreatives({ + 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: { + 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 { + console.log("Validation passed! Ready to sync."); +} +``` + +```python Python +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.sync_creatives( + 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': { + '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}") + + 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 +import { testAgent } from "@adcp/client/testing"; +import { SyncCreativesResponseSchema } from "@adcp/client"; + +// Update just 2 creatives out of 100+ in the library +const result = await testAgent.syncCreatives({ + 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: { + 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: { + 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 +import asyncio +from adcp.testing import test_agent + +async def main(): + # Update just 2 creatives out of 100+ in the library + result = await test_agent.simple.sync_creatives( + 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': { + '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': { + '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.0.13/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 + +- Validates request without making changes +- Returns errors and warnings +- Does not process assets or create creatives +- Use for pre-flight validation checks + +## Error handling + +| Error Code | Description | Resolution | +| ------------------------- | ------------------------------------------------- | -------------------------------------------------------------------- | +| `INVALID_FORMAT` | Format not supported by product | Check product's supported formats via `list_creative_formats` | +| `ASSET_PROCESSING_FAILED` | Asset file corrupt or invalid | Verify asset meets format requirements (codec, dimensions, duration) | +| `PACKAGE_NOT_FOUND` | Package ID doesn't exist in media buy | Verify `package_id` from `create_media_buy` response | +| `BRAND_SAFETY_VIOLATION` | Creative failed brand safety scan | Review content against publisher's brand safety guidelines | +| `FORMAT_MISMATCH` | Assets don't match format requirements | Verify asset types and specifications match format definition | +| `CREATIVE_IN_ACTIVE_DELIVERY` | 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 #7). + +2. **Validate first** - Use `dry_run: true` to catch errors before actual upload. This saves bandwidth and processing time. + +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. **Check format support** - Use `list_creative_formats` to verify product supports your creative formats before uploading. + +7. **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.0.13/creative/task-reference/list_creative_formats) - Check supported formats before upload +- [`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives) - Browse and filter creatives in a library +- [`build_creative`](/dist/docs/3.0.13/creative/task-reference/build_creative) - Build manifests from library creatives or generate from scratch +- [`preview_creative`](/dist/docs/3.0.13/creative/task-reference/preview_creative) - Generate previews of creative manifests +- [Creative Asset Types](/dist/docs/3.0.13/creative/asset-types) - Technical requirements for assets diff --git a/dist/docs/3.0.13/creative/template-format-ids.mdx b/dist/docs/3.0.13/creative/template-format-ids.mdx new file mode 100644 index 0000000000..167e172347 --- /dev/null +++ b/dist/docs/3.0.13/creative/template-format-ids.mdx @@ -0,0 +1,583 @@ +--- +title: Template Format IDs +description: "Template format IDs in AdCP let a single format definition support many dimension variants without creating separate formats for each size." +"og:title": "AdCP — Template Format IDs" +--- + + +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 +{"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 +{ + "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 +{ + "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 +{ + "placement_id": "homepage_banner" + "name": "Homepage Banner" + "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 +{ + "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 +{ + "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 +{ + "agent_url": "https://creative.adcontextprotocol.org" + "id": "video_hosted" + "width": 1920 + "height": 1080 + "duration_ms": 30000 +} +``` + +## Format Definition Patterns + +### Display Format (Flexible Dimensions) + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "products": [{ + "placements": [{ + "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 + { + "placements": [{ + "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 +// 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 +// 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 +// 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 +{ + "format_id": {"agent_url": "...", "id": "display_static"} + "accepts_parameters": ["dimensions"] + "assets": [...] +} +``` + +**Creative manifest change:** +```json +// 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 +{ + "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 +{ + "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 +{ + "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.0.13/creative/creative-manifests) - Complete manifest structure +- [Format Discovery](/dist/docs/3.0.13/creative/formats) - How buyers discover formats +- [Placement Targeting](/dist/docs/3.0.13/media-buy/creatives) - Assigning creatives to placements +- [Format References](/dist/docs/3.0.13/protocol/format-references) - Normative contrast between `format_id` (pointer) and `format` (definition), including named validation errors diff --git a/dist/docs/3.0.13/creative/universal-macros.mdx b/dist/docs/3.0.13/creative/universal-macros.mdx new file mode 100644 index 0000000000..cb9462e565 --- /dev/null +++ b/dist/docs/3.0.13/creative/universal-macros.mdx @@ -0,0 +1,677 @@ +--- +title: Universal Macros +description: "Universal macros in AdCP insert dynamic tracking data into creatives with platform-agnostic placeholders replaced at impression time." +"og:title": "AdCP — Universal Macros" +--- + + +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 +// 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` | + +### 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.0.13/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.0.13/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.0.13/trusted-match), this is replaced by: +- **Structured creative assets** move to the `creative_manifest` field on the [Offer](/dist/docs/3.0.13/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.0.13/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}`. + +#### 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 (`{CATALOG_ID}`, `{SKU}`, `{GTIN}`, `{OFFERING_ID}`, `{JOB_ID}`, `{HOTEL_ID}`, `{FLIGHT_ID}`, `{VEHICLE_ID}`, `{LISTING_ID}`, `{STORE_ID}`, `{PROGRAM_ID}`, `{DESTINATION_ID}`): + +- **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 tracked at [`static/test-vectors/catalog-macro-substitution.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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) + +### 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 +// 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 +// 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 +// 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 +{ + '{CACHEBUSTER}': '%%CACHEBUSTER%%', + '{DEVICE_ID}': '%%ADVERTISING_IDENTIFIER_PLAIN%%', + '{DEVICE_ID_TYPE}': '%%ADVERTISING_IDENTIFIER_TYPE%%', + '{DOMAIN}': '%%SITE%%', + '{VIDEO_ID}': '%%VIDEO_ID%%' +} +``` + +**Kevel**: +```javascript +{ + '{CACHEBUSTER}': '{{timestamp}}', + '{DEVICE_ID}': '{{device.ifa}}', + '{DEVICE_ID_TYPE}': '{{device.ifaType}}', + '{DOMAIN}': '{{request.domain}}' +} +``` + +**Xandr Monetize**: +```javascript +{ + '{CACHEBUSTER}': '${CACHEBUSTER}', + '{DEVICE_ID}': '${DEVICE_APPLE_IDA}', // or ${DEVICE_AAID} + '{DOMAIN}': '${DOMAIN}' +} +``` + +### Click Tracker Insertion + +Sales agents must automatically insert click tracking macros into clickable elements: + +**Original creative**: +```html +
Click here +``` + +**After insertion (GAM)**: +```html +Click here +``` + +### Mapping Storage + +Store the mapping between AdCP IDs and ad server IDs for reconciliation: + +```javascript +{ + 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.0.13/creative/formats) - Understanding format specifications and discovery +- [Creative Protocol](/dist/docs/3.0.13/creative) - How creatives work in AdCP +- [sync_creatives](/dist/docs/3.0.13/creative/task-reference/sync_creatives) - Creative management API \ No newline at end of file diff --git a/dist/docs/3.0.13/curation/coming-soon.mdx b/dist/docs/3.0.13/curation/coming-soon.mdx new file mode 100644 index 0000000000..774640628b --- /dev/null +++ b/dist/docs/3.0.13/curation/coming-soon.mdx @@ -0,0 +1,41 @@ +--- +title: Curation Protocol +description: "AdCP curation protocol (coming soon): curated marketplace discovery, deal packaging, and inventory curation for AI advertising agents." +"og:title": "AdCP — Curation Protocol" +sidebarTitle: Coming Soon +--- + + +The Curation Protocol will enable AI assistants to discover and package media inventory based on campaign objectives, context, and brand safety requirements. + +## Planned Features + +### Inventory Discovery +- Natural language description of desired inventory +- Contextual category matching +- Cross-platform inventory packaging + +### Brand Safety +- Keyword exclusions +- Category blocks +- Custom safety profiles +- Third-party verification integration + +### Package Creation +- Multi-publisher deals +- Audience + inventory bundles +- Preferred pricing tiers + +## Expected Timeline + +- **Coming Soon**: Initial specification draft and reference implementation + +## Get Involved + +Want to help shape the Curation Protocol? + +- Join our [working group](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) +- Share your use cases +- Review early drafts + +Subscribe to updates: announcements@adcontextprotocol.org \ No newline at end of file diff --git a/dist/docs/3.0.13/faq.mdx b/dist/docs/3.0.13/faq.mdx new file mode 100644 index 0000000000..a21fa80bea --- /dev/null +++ b/dist/docs/3.0.13/faq.mdx @@ -0,0 +1,356 @@ +--- +title: Frequently asked questions +sidebarTitle: FAQ +description: "Answers to common questions about AdCP — licensing (Apache 2.0, free to use), how it relates to OpenRTB and IAB standards, who maintains it (AgenticAdvertising.org), and how to start implementing." +"og:title": "AdCP — Frequently asked questions" +--- + +## About agentic advertising + + + + +When you ask an AI assistant for a product recommendation, the assistant can surface relevant brands — similar to how a retail platform shows sponsored products when you search. The brand pushes its product catalog, brand identity, and content standards into the platform ahead of time. When a user's question matches, the AI generates a contextually relevant recommendation from that data. This is called [Sponsored Intelligence](/dist/docs/3.0.13/sponsored-intelligence/overview), and the content is always clearly labeled as sponsored — the same way sponsored search and retail media placements are labeled. + + + +AI platforms are beginning to offer advertising, and the market is growing. AdCP provides a standard protocol so your buyer agent can connect to any AI platform that implements it — without building a custom integration for each one. Your buyer agent discovers available inventory from connected sellers in real time via [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products). + + + +SEO for AI (sometimes called GAIO or generative AI optimization) focuses on getting your brand mentioned in organic AI responses by optimizing your public content. [Sponsored Intelligence](/dist/docs/3.0.13/sponsored-intelligence/overview) is paid advertising — brands push structured product data, brand identity, and optimization goals into AI platforms through a standard protocol, and the platform generates clearly labeled sponsored content. The two approaches are complementary, not competing. + + + + + +**Experimental.** Sponsored Intelligence is part of AdCP 3.0 as an experimental surface (feature id `sponsored_intelligence.core`) — session lifecycle, UI components, identity/consent object shape, and capability negotiation may change between 3.x releases with at least 6 weeks' notice. Pilot implementations are encouraged; regulated or compliance-sensitive workflows should wait for graduation to stable. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +For a practical guide to buying ads on AI platforms, see the [monetizing AI guide](/dist/docs/3.0.13/sponsored-intelligence/monetizing-ai). For the technical protocol walkthrough, see the [Sponsored Intelligence overview](/dist/docs/3.0.13/sponsored-intelligence/overview). + +## Buying AI media + + + + +Both paths work. Agencies, ad networks, and commerce platforms can implement AdCP on your behalf — you provide product data and brand guidelines, they handle the protocol plumbing across AI platforms. If you run programmatic in-house, you (or your engineering team) can build a buyer agent directly against AdCP. The [monetizing AI guide](/dist/docs/3.0.13/sponsored-intelligence/monetizing-ai#getting-started-by-role) walks through options for brands, agencies, and small businesses. + + + +Yes. Brands push [brand identity](/dist/docs/3.0.13/brand-protocol/brand-json) (voice, visual guidelines, positioning) and [content standards](/dist/docs/3.0.13/governance/content-standards) (approved claims, topics to avoid, suitability rules) into AI platforms through the protocol. Platforms enforce these at generation time — before content is shown — rather than filtering after the fact. See [governance](/dist/docs/3.0.13/governance/overview) for the full model. + + + +Pricing varies by platform and format. Common models include CPC (cost per click) for sponsored responses and AI search results, and per-session pricing for conversational brand experiences via SI Chat Protocol. Your buyer agent discovers available pricing from connected sellers through [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) — each product lists its pricing options. + + + +AI assistants, search copilots, and conversational platforms are live today. The ecosystem is early-stage and growing. Your buyer agent discovers available inventory from connected sellers in real time via [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) — you always see what's currently reachable. + + + +AdCP does not specify attribution or viewability — it is not an MRC-accredited measurement standard. The protocol carries the delivery and usage data that your existing IAS, DV, Nielsen, Comscore, or attribution tools consume; you keep your existing measurement contracts and accreditations. See [Known Limitations](/dist/docs/3.0.13/reference/known-limitations) for the full list of what AdCP does not standardize. + + + +Yes. AdCP is additive to existing agency relationships. Your agency can use buyer agents to extend their capabilities, or you can work with AdCP-certified practitioners. + + + +If your agency already supports AdCP, you can be live in days. If not, the [monetizing AI guide](/dist/docs/3.0.13/sponsored-intelligence/monetizing-ai#getting-started-by-role) walks through options for brands, agencies, and small businesses — including working with an AdCP-certified partner. + + + + + + What you need, what data to provide, and how to find a partner — whether you're a brand, agency, or small business. + + +## About AdCP + + + + +AdCP (Ad Context Protocol) is an open protocol that lets AI agents collaborate across advertising platforms using a standardized language — spanning product discovery, media buying, creative generation, audience activation, and brand governance. + +[Embedded human judgment](/dist/docs/3.0.13/governance/embedded-human-judgment) keeps humans accountable: agent actions are reviewed and approved before execution. + +AdCP is a specification — not a product, platform, or company. Anyone can implement it. Start with the [introduction](/dist/docs/3.0.13/intro) or the [building guide](/dist/docs/3.0.13/building). + + + +AdCP is developed and maintained by [AgenticAdvertising.org](https://agenticadvertising.org) (AAO), a Delaware nonprofit trade association (501(c)(6) status pending with the IRS) with four equal voting classes — Brands, Agencies, Publishers, and Technology Providers — and a target of ten elected seats per class at steady state. + +The Foundation currently operates under an interim board appointed at incorporation: Michael Blum (Scope3), Brian O'Kelley (Scope3), Pia Malovrh (Celtra), and Benjamin Masse (Triton Digital). The interim board is replaced by the elected board at the first Annual General Meeting on **May 6, 2026**. Two of four interim seats are Scope3-affiliated, reflecting Scope3's seed contributions; see [How is AAO related to Scope3?](#how-is-aao-related-to-scope3) for the full relationship, named recusal areas, and the transition to equal voting-class representation. + +Day-to-day protocol work happens in public working groups on [GitHub](https://github.com/adcontextprotocol/adcp), with every change auditable in Git history. Contributors and organizations who have shaped the protocol through issues, pull requests, and working-group participation are named in [CONTRIBUTORS.md](https://github.com/adcontextprotocol/adcp/blob/main/CONTRIBUTORS.md). + + + +To pioneer a more intelligent, human-centric advertising future through agentic AI — pairing the scale of AI with the power of human judgment. + +Three pillars support the mission: [open standards](https://docs.adcontextprotocol.org) (AdCP), [education](/dist/docs/3.0.13/learning/overview) (the Academy and certification program), and [governance](/dist/docs/3.0.13/governance/overview) (frameworks that keep humans in the decisions that matter). + + + +Yes. AdCP is open source under the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0). There is no cost to use, implement, or license the protocol, and no permission required. The specification, [JSON schemas](https://adcontextprotocol.org/schemas/3.0.13/), and documentation are freely available — no fee, no license agreement, no membership requirement. + + + +AdCP is at **3.0.1 — General Availability**, with 3.0 released April 2026. The protocol is stable and production-ready. See [Release notes](/dist/docs/3.0.13/reference/release-notes) for the full change log and [What's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3) for the 2.5 → 3.0 migration summary. + +The next major version (4.0) is targeted for early 2027. Under the [release cadence policy](/dist/docs/3.0.13/reference/versioning#release-cadence), majors are at least 18 months apart, the previous major receives security patches for at least 12 months after successor GA, and deprecation notices are published at least 6 months before removal. See [Versioning & Governance](/dist/docs/3.0.13/reference/versioning) for the full policy and 3.x stability guarantees. AdCP 2.5 remains security-patched until 2026-08-01 — see the [v2 sunset page](/dist/docs/3.0.13/reference/v2-sunset) for the end-of-life timeline. + + + +AdCP 3.0 introduces breaking changes. Start with [what's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3) for the summary, then work through the [migration guides](/dist/docs/3.0.13/reference/migration) by topic — channels, pricing, creatives, catalogs, geo targeting, optimization goals, brand identity, and audiences. New protocol domains (accounts, governance, brand protocol) are additive; existing integrations can adopt them incrementally. + + + + +## How AdCP relates to other standards + + + + +No. AdCP and OpenRTB operate at different layers and are complementary. + +| | OpenRTB | AdCP | +|---|---------|------| +| **Scope** | Impression-level transactions | Agent-level workflows | +| **Operations** | Bid requests, bid responses, win notifications | Product discovery, media buy creation, creative generation, audience activation | +| **Participants** | DSPs and SSPs | AI agents and advertising platforms | +| **Timing** | Real-time (milliseconds) | Asynchronous (seconds to days) | + +A platform can implement both. For example, a publisher's AdCP agent might accept a [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) task from a buyer agent, then use OpenRTB internally to execute the impression-level delivery. AdCP handles the workflow; OpenRTB handles the auction. + + + +AdCP is a separate specification from IAB Tech Lab standards. AgenticAdvertising.org is an independent organization. However, AdCP is designed to be compatible with IAB standards — for example, AdCP's content taxonomy fields align with IAB content categories, and AdCP's audience segments can reference IAB audience taxonomy IDs. + + + +AAMP — [IAB Tech Lab's Agentic Advertising Management Protocols framework](https://iabtechlab.com/standards/) — is an emerging suite of agentic-advertising initiatives (including an Agent Registry and Agentic Audiences work streams). Based on AAMP materials published to date, AdCP and AAMP appear to operate at different layers of the stack and can coexist. + +Our read of the distinction: AdCP describes how a buyer agent and a seller agent negotiate, transact, and govern a media buy — product discovery, pricing, creative, governance, and execution. AAMP's work streams, as currently described, address impression-level concerns: how agents are discovered and identified inside the existing programmatic auction, and how agentic audiences move across that layer. A platform could implement both, with AdCP handling the buyer/seller workflow and AAMP's components plugging into the impression-level layer alongside OpenRTB. + +As of April 2026: + +| | AAMP | AdCP | +|---|---|---| +| **Layer** | Impression-level (within the RTB stack, per published work streams) | Buyer/seller workflow (product discovery, media buy, creative, governance, execution) | +| **Maintainer** | IAB Tech Lab | AgenticAdvertising.org | +| **Maturity** | Emerging framework across multiple sub-initiatives | 3.0 GA (released April 2026) | +| **Scope** | Umbrella for multiple agentic initiatives | Single specification covering media buying, creative, signals, brand governance, and execution (TMP) | +| **Governance verification** | Being defined | Signed governance context with 15-step verification (Layer 4 of the [security model](/dist/docs/3.0.13/building/concepts/security-model)) | +| **Public schemas** | Being defined | [Published](https://adcontextprotocol.org/schemas/3.0.13/), Apache 2.0 | + +We do not yet publish a formal technical comparison because AAMP is still defining its normative surface. Implementers interested in both should watch both specifications as they stabilize. + + + +Google's Universal Commerce Protocol (UCP) — developed with Shopify, Walmart, Target, and others — and OpenAI and Stripe's Agentic Commerce Protocol (ACP) standardize *commerce* in AI assistants: checkout, payments, and fulfillment. AdCP standardizes *advertising*: how offers get surfaced, how a brand agent engages a user, and how attribution flows back. + +These are different layers, not competing specifications. + +| Layer | Standard | What it does | +|---|---|---| +| Commerce | UCP, ACP | Checkout, payments, fulfillment, order status | +| Advertising | AdCP | Sponsored discovery, media buying, creative, brand governance, attribution | + +The layers meet at the handoff. The [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) runs a conversational brand experience inside an AI assistant; when the user decides to buy, the host hands off to ACP or UCP for checkout, carrying the SI `session_id` through as context so the transaction can be attributed back to the sponsored conversation. The commerce protocol's own session owns the purchase flow. AdCP owns the path up to the handoff; the commerce protocol owns the transaction. + +A platform implementing both sees AdCP for "surface the right offer and engage the user" and UCP/ACP for "take the payment." Neither substitutes for the other. + + + +You can, and for some deployments that is the right call — proprietary internal protocols are fine when the scope stays inside a single organization. + +A shared protocol earns its cost when two conditions hold: (1) agents need to interoperate across organizational boundaries, and (2) the guarantees an implementer needs — idempotency, signed governance, structural privacy separation, conformance — are nontrivial to build from scratch. AdCP already specifies the wire, publishes schemas, runs conformance tests, and carries a signed-governance profile with 15-step verification (see the [security model](/dist/docs/3.0.13/building/concepts/security-model)). Rebuilding that internally is a real cost, and if you want counterparties to trust your internal protocol, you have to socialize it anyway. + +If AdCP is missing something you need, the cheaper path is usually: extend via `ext.{vendor}`, or propose a change to the working group. See the [contributing guide](https://github.com/adcontextprotocol/adcp/blob/main/CONTRIBUTING.md). + + + +MCP defines *how* an agent calls a tool. AdCP defines *what* the agent says when it calls an advertising tool. An MCP server that exposes ad-buying tools without AdCP defines its own task shapes, its own response schemas, its own error codes, and its own governance semantics — and every buyer agent has to integrate one server at a time. + +AdCP is what makes the tools interchangeable. If every publisher's MCP server speaks AdCP's `create_media_buy`, one buyer agent can integrate with all of them. Without AdCP, "connect to a new publisher" is a new development task every time. + +Put differently: MCP is the transport, AdCP is the protocol. You use MCP to *carry* AdCP tasks — the same way HTTP carries REST payloads. See [Industry landscape](/dist/docs/3.0.13/building/concepts/industry-landscape) for how AdCP, OpenRTB, MCP, and A2A relate. + + + +AdCP is built for agentic workflows — direct-sold inventory, guaranteed deals, and commerce media — not the impression-level auction where OpenRTB already works. Buyers and sellers transact directly through their agents, with pricing surfaced via `pricing_options`, `price_guidance`, and (when the transaction warrants it) `price_breakdown`. + +The supply-path-optimization concerns that drove SPO disclosure in programmatic auctions look different in direct-sold transactions, where buyer and seller are authenticated counterparties rather than anonymous bidders. Buyers that want SPO-grade fee disclosure can require it through `buy_terms` as a condition of the purchase — the protocol supports it; it's not imposed as a protocol-wide mandate. + + + +`adagents.json` extends the authorization model for agentic buying while preserving the ads.txt/sellers.json relationship semantics. A publisher's `adagents.json` with `delegation_type` carries the same signal as an ads.txt `DIRECT` or `RESELLER` row, and `brand.json` properties with a `relationship` field carry the same signal as a sellers.json entry. + +The full crosswalk is in [Why adagents.json instead of ads.txt](/dist/docs/3.0.13/governance/property/adagents#why-adagents-json-instead-of-ads-txt). + + + +These verticals fall under GDPR Art 22, EU AI Act Annex III, and US FHA / ECOA / EEOC. The policy-category mechanism already ships with `fair_housing`, `fair_lending`, and `fair_employment` entries in the registry. + +AdCP 3.0 GA will require campaigns declaring a regulated policy category to run with human review — `authority_level: agent_full` will not be accepted — with an Annex III category taxonomy and a data-subject contestation path added at the same time. Tracked in [#2310](https://github.com/adcontextprotocol/adcp/issues/2310). Until that ships, enforcement depends on the governance agent implementation, not a schema invariant. See the [governance overview](/dist/docs/3.0.13/governance/overview) for the full model. + + + +AdCP is governed by AgenticAdvertising.Org (AAO), a pending 501(c)(6) trade association incorporated in Delaware. Governance is summarized in [CHARTER.md](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md) in the repository, with the authoritative materials (Bylaws, Membership Agreement, IPR Policy, Antitrust Policy) at [agenticadvertising.org/governance](https://agenticadvertising.org/governance). + +The interim board (as of 2026-04-18) has four directors: Michael Blum (Scope3), Brian O'Kelley (Scope3), Pia Malovrh (Celtra), and Benjamin Masse (Triton Digital). The elected board — first Annual General Meeting on **May 6, 2026** — has equal representation across four voting classes (Brands, Agencies, Publishers, Technology Providers), with a target of ten seats per class. Day-to-day protocol work happens in [working groups](/dist/docs/3.0.13/community/working-group); change proposals flow through this repository. + +**Reference sell-side implementation lives at Prebid.** Development of the sell-side AI agent reference code was handed to the [Prebid community](https://www.prebid.org/) in February 2026, [reported by AdExchanger](https://www.adexchanger.com/ad-exchange-news/the-agentic-advertising-organization-hands-development-of-its-sell-side-agent-to-prebid/). AAO owns the specification; Prebid owns the reference software. Spec governance and reference-implementation development are intentionally separate organizations. + + + +No. All examples in docs, storyboards, and test vectors use fictional entities — Acme Outdoor, Nova Motors, Pinnacle Agency, StreamHaus, and the other names registered in `static/compliance/source/universal/fictional-entities.yaml`. Real brands, agencies, publishers, and vendors do not appear in normative examples. The editorial rule is enforced in [`CLAUDE.md`](https://github.com/adcontextprotocol/adcp/blob/main/CLAUDE.md) and called out in [CONTRIBUTING.md](https://github.com/adcontextprotocol/adcp/blob/main/CONTRIBUTING.md). Reviewers flag real-brand usage the same way they flag vendor leakage in schemas. This is a deliberate choice to keep the protocol neutral: the spec shouldn't favor one seller, agency, or vendor by name. + + + +**Two of four interim board seats are Scope3-affiliated.** The interim board has four directors: Michael Blum (Scope3), Brian O'Kelley (Scope3), Pia Malovrh (Celtra), and Benjamin Masse (Triton Digital). This composition reflects Scope3's seed contributions to AAO and transitions to an elected board at the first Annual General Meeting on **May 6, 2026**, which carries equal representation across four voting classes (Brands, Agencies, Publishers, Technology Providers) — ten seats per class at steady state. + +Scope3 contributed foundational IP and early funding. Specifically: + +- **CSBS (Common Sense Brand Standards)** — formerly "Scope3 Common Sense" — was donated to AAO and is now governed by AAO. The formal donation and rename are tracked in [#2305](https://github.com/adcontextprotocol/adcp/issues/2305). +- **Property registry seed data** — the initial property universe and ad-infrastructure knowledge graph seeding the AAO property catalog was donated by Scope3. +- **Seed funding loan** — Scope3 provided a seed funding loan to AAO, repaid from membership revenue on a scheduled basis. Terms are disclosed in the annual financial report to members. + +Brian co-founded both Scope3 and AAO and serves as AdCP lead architect; he does not hold executive authority at AAO, and Scope3 holds no voting rights, veto, or protocol-control privileges beyond those of any other member. Because of the dual role, he recuses from AAO decisions where Scope3 has a direct commercial interest — including any change to the default brand-safety framework, property-catalog data governance, and the terms of the seed-loan repayment. + +See [CHARTER.md](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md) for the governance framework and [agenticadvertising.org/governance](https://agenticadvertising.org/governance) for the authoritative board list, funding disclosures, and recusal rules. + + + +AdCP today uses bearer-token authentication between agents — see [authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication) for the shipped model. + +AdCP 3.1 will add request signing for mutating calls (`create_*`, `update_*`, `sync_*`, `activate_*`, `acquire_*`) via RFC 9421 HTTP Signatures or JWS-signed bodies as a normative requirement, with sellers verifying against the buyer's published signing keys. Bearer tokens alone will not be sufficient for mutating calls. Tracked in [#2307](https://github.com/adcontextprotocol/adcp/issues/2307). + +Governance decisions will also be signed so that a seller or regulator can verify a `governance_context` token genuinely came from the issuing governance agent. Tracked in [#2306](https://github.com/adcontextprotocol/adcp/issues/2306). Until those land, implementers should treat bearer auth as an interim floor, not the long-term contract. + + + +Yes. AdCP's `sponsored_intelligence` channel covers advertising within AI assistants, AI search engines, and generative AI experiences — including sponsored responses, AI search sponsored results, generative display, and brand experience handoffs via SI Chat Protocol. AI platforms and ad networks implement AdCP the same way any seller does: publish [`adagents.json`](/dist/docs/3.0.13/governance/property/adagents), implement [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) with `channels: ["sponsored_intelligence"]`, and accept media buys. See the [Sponsored Intelligence protocol](/dist/docs/3.0.13/sponsored-intelligence/overview) for product modeling, workflows, and measurement. + +Sponsored Intelligence is an [experimental surface](/dist/docs/3.0.13/reference/experimental-status) in 3.0 (feature id `sponsored_intelligence.core`) — sellers implementing it MUST declare `sponsored_intelligence.core` in `experimental_features`, and buyers SHOULD check that declaration before relying on SI tasks. The surface may change between 3.x releases with at least 6 weeks' notice. + + + +AdCP uses [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) and [A2A (Agent-to-Agent Protocol)](https://google.github.io/A2A/) as transport layers. Think of it this way: + +- **MCP and A2A** define how agents communicate (the transport) +- **AdCP** defines what agents say about advertising (the domain) + +AdCP tasks are the same regardless of transport. A [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) call has the same request schema and response schema whether it travels over MCP or A2A. See [protocol comparison](/dist/docs/3.0.13/building/concepts/protocol-comparison) for details on how the two transports differ. + + + +No. Platform APIs (self-serve dashboards, management APIs) serve a different purpose than AdCP. Platform APIs expose the full, proprietary feature set of a single platform. AdCP provides a standardized interface for common advertising operations across platforms. + +A platform that implements AdCP does not need to replace its existing API. AdCP sits alongside it, providing a standard interface that AI agents can use for cross-platform workflows. + + + + +## Certification + + + + +Start a conversation with Addie, our AI teaching assistant. She'll guide you through [interactive modules](/dist/docs/3.0.13/learning/overview) at your own pace. + + + +The Basics track has 3 modules, about 50 minutes total. Most learners finish in a few focused sessions. Practitioner tracks add 4 more modules (~90–105 minutes depending on your role track). + + + +The Basics track is free and open to everyone. Practitioner and Specialist tracks require AgenticAdvertising.org membership. + + + +Not for the Basics track. The Practitioner track includes a build project, but it uses vibe coding — you describe what you want in plain language and an AI agent writes the code. + + + +Yes. That's the point. The Practitioner build project is designed so that anyone — including marketing executives with zero coding experience — can build a working advertising agent through conversation with an AI coding assistant. + + + +Vibe coding means describing what you want in plain language and having an AI coding assistant build it. No syntax, no prior programming experience needed. You iterate by describing what to change — the AI handles the code. In the certification build project, you'll vibe-code a working advertising agent. + + + +That's expected. Two or three iteration cycles is normal, not a sign you're failing. When you hit an error, copy it back to your AI coding assistant and describe what you were trying to do. Addie coaches you through the debug loop rather than debugging for you — you'll leave the program knowing how to iterate with AI on real projects. + + + +Yes. Every module has 3–5 required demonstrations — specific things you must do or explain during the conversation. These are identical for all learners, enforced by the system, and cannot be skipped. Addie adapts the *teaching* to your background, but the *bar* is the same for everyone. An experienced ad tech executive and a newcomer both verify the same core competencies. See [assessment fairness](/dist/docs/3.0.13/learning/instructional-design#assessment-fairness) for details. + + + +AdCP evolves. When a protocol update changes what certified professionals should know, the system identifies which credentials are affected and notifies holders about what changed. Recertification is targeted — if the update affects creative workflows but not media buying, only creative-related credentials are flagged. You won't be asked to redo material that hasn't changed. + + + + + + Open Addie and say "I want to get certified." The Basics track is free — no account required. + + +## Getting involved + + + + +Read the [introduction](/dist/docs/3.0.13/intro) for an overview, then explore the domain that matches your use case: + +- **Sell-side platforms**: Start with [media buy](/dist/docs/3.0.13/media-buy) to expose your inventory +- **Creative platforms**: Start with [creative](/dist/docs/3.0.13/creative) to offer format discovery and ad generation +- **Data providers**: Start with [signals](/dist/docs/3.0.13/signals/overview) to make audiences addressable by agents +- **Orchestrators and agencies**: Start with the [integration guide](/dist/docs/3.0.13/building/by-layer/L0) to connect to existing AdCP agents + +JSON schemas for all tasks are available at [adcontextprotocol.org/schemas](https://adcontextprotocol.org/schemas/3.0.13/). + + + +Both. AgenticAdvertising.org membership is open to individuals and companies. If you work in advertising — as a trader, media planner, buyer, agency strategist, or any other role — you can join as an individual member and benefit from: + +- **Certification** — The Practitioner and Specialist tracks teach you how agentic advertising works and how to build advertising agents, regardless of technical background. The Basics track is free and open to everyone. +- **Community** — Connect with others navigating the same transition from programmatic to agentic, across roles and companies. +- **Working groups** — Participate in groups that shape protocol direction. Your operational perspective as a practitioner is valuable — protocols built only by engineers miss real-world workflow needs. +- **Professional development** — Agentic advertising is early. Getting certified now positions you ahead of the curve as the industry adopts AI-driven workflows. + +You do not need to be an engineer or represent a company to join. Individual membership is designed for practitioners who want to learn, contribute, and stay ahead of the industry shift. + + + +Membership gives you access to the community, certification, and governance: + +- **Certification** — Practitioner and Specialist credential tracks, including a hands-on build project where you create a working advertising agent +- **Working groups** — Participate in groups that shape protocol direction and vote on proposed changes +- **Member directory** — List your organization's capabilities so others can find you as a partner or vendor +- **Community** — Connect with implementers, practitioners, and decision-makers across the industry + +Membership is not required to implement AdCP or to complete the free Basics certification track. See the [working group](/dist/docs/3.0.13/community/working-group) page for how to get involved. + + + +Yes. The protocol is developed in the open. You can: + +- File issues and feature requests on [GitHub](https://github.com/adcontextprotocol/adcp/issues) +- Join the community Slack to ask questions and discuss implementations +- Submit pull requests with bug fixes or documentation improvements +- Build and publish your own AdCP implementation + +Membership is for individuals and organizations that want to go deeper — certification, working groups, and formal influence over protocol governance. + + + + + + Implementation guides, SDKs, and integration patterns. + diff --git a/dist/docs/3.0.13/governance/annex-iii-obligations.mdx b/dist/docs/3.0.13/governance/annex-iii-obligations.mdx new file mode 100644 index 0000000000..3761e6b3f2 --- /dev/null +++ b/dist/docs/3.0.13/governance/annex-iii-obligations.mdx @@ -0,0 +1,119 @@ +--- +title: Annex III & Article 22 obligations +sidebarTitle: Annex III & Art 22 +description: "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." +"og:title": "AdCP — Annex III & Art 22 obligations" +--- + +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 +{ + "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.0.13/governance/policy-registry#seeded-policies) — the seeded registry policy +- [Policy registry](/dist/docs/3.0.13/governance/policy-registry) — `requires_human_review` on policies and categories +- [Campaign governance specification](/dist/docs/3.0.13/governance/campaign/specification) — `human_review_required` plan field +- [Embedded human judgment](/dist/docs/3.0.13/governance/embedded-human-judgment) — the architectural principle behind mandatory oversight diff --git a/dist/docs/3.0.13/governance/campaign/audit-trail.mdx b/dist/docs/3.0.13/governance/campaign/audit-trail.mdx new file mode 100644 index 0000000000..2219c04c2a --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/audit-trail.mdx @@ -0,0 +1,317 @@ +--- +title: "Audit trail: internal vs shareable views" +sidebarTitle: Audit trail +description: "How AdCP's campaign governance audit trail splits into a full internal view and scoped shareable views, and what each counterparty can safely see." +"og:title": "AdCP — Governance audit trail" +--- + +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.0.13/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.0.13/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`, `status` | 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 +{ + "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 +{ + "governance_context": "gc_mb_seller_456", + "status": "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 +{ + "$schema": "/schemas/3.0.13/governance/get-plan-audit-logs-response.json", + "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", + "status": "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 +{ + "$schema": "/schemas/3.0.13/governance/check-governance-response.json", + "check_id": "chk_0ac8d7de", + "plan_id": "plan_2027_apex_athletic", + "status": "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 +{ + "$schema": "/schemas/3.0.13/governance/check-governance-response.json", + "check_id": "chk_8360a36d", + "plan_id": "plan_2027_nova_mortgage", + "status": "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.0.13/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 +{ + "$schema": "/schemas/3.0.13/governance/check-governance-response.json", + "check_id": "chk_enforce_001", + "plan_id": "plan_mode_enforce", + "status": "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 +{ + "$schema": "/schemas/3.0.13/governance/check-governance-response.json", + "check_id": "chk_advisory_001", + "plan_id": "plan_mode_advisory", + "status": "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 +{ + "$schema": "/schemas/3.0.13/governance/check-governance-response.json", + "check_id": "chk_audit_001", + "plan_id": "plan_mode_audit", + "status": "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.0.13/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.0.13/governance/policy-registry#policy-categories). + +## Related + +- [`get_plan_audit_logs`](/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs) — request and response schema +- [Campaign governance specification](/dist/docs/3.0.13/governance/campaign/specification) — plan binding, drift detection, governance context lifecycle +- [Policy Registry](/dist/docs/3.0.13/governance/policy-registry) — policy versioning, `effective_date`, registry vs inline policies +- [Annex III & Art 22 obligations](/dist/docs/3.0.13/governance/annex-iii-obligations) — when human review is required diff --git a/dist/docs/3.0.13/governance/campaign/index.mdx b/dist/docs/3.0.13/governance/campaign/index.mdx new file mode 100644 index 0000000000..739be9417e --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/index.mdx @@ -0,0 +1,242 @@ +--- +title: Campaign Governance +description: "AdCP Campaign Governance validates autonomous media buys against authorized plans, budget limits, and compliance policies using three-party trust." +"og:title": "AdCP — Campaign Governance" +sidebarTitle: Overview +"og:image": /images/walkthrough/diagram-governance-triangle.png +--- + +# 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.0.13/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.0.13/governance/campaign/tasks/sync_plans), then calls [`check_governance`](/dist/docs/3.0.13/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.0.13/governance/campaign/tasks/report_plan_outcome) to close the loop. The governance agent tracks budget from confirmed outcomes -- not just attempted actions. + +```mermaid +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/governance/creative/index)** | Campaign Governance validates that creative assignments match format requirements. Creative Governance scans the creatives themselves. | +| **[Brand Protocol](/dist/docs/3.0.13/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 + + + + 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`. + + diff --git a/dist/docs/3.0.13/governance/campaign/safety-model.mdx b/dist/docs/3.0.13/governance/campaign/safety-model.mdx new file mode 100644 index 0000000000..d034248fbf --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/safety-model.mdx @@ -0,0 +1,143 @@ +--- +title: Safety Model +description: "AdCP three-party trust prevents any single AI agent from making unilateral media buying decisions. Orchestrator, governance, and seller validate independently." +"og:title": "AdCP — Safety Model" +sidebarTitle: Safety Model +--- + + +# 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 +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.0.13/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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/governance/campaign/specification.mdx b/dist/docs/3.0.13/governance/campaign/specification.mdx new file mode 100644 index 0000000000..c2f124a548 --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/specification.mdx @@ -0,0 +1,1073 @@ +--- +title: Specification +description: "Formal specification for AdCP campaign governance — plan schemas, budget authority models, validation logic, and integration patterns." +"og:title": "AdCP — Specification" +sidebarTitle: Specification +--- + + +# 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.0.13/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.0.13/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 +{ + "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 +{ + "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.0.13/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.0.13/media-buy/task-reference/create_media_buy) — committed budget across packages +- [`update_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/update_media_buy) — incremental commit delta (new budget − previously committed) +- [`acquire_rights`](/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights) — rights pricing +- [`update_rights`](/dist/docs/3.0.13/brand-protocol/tasks/update_rights) — incremental commit delta +- [`activate_signal`](/dist/docs/3.0.13/signals/tasks/activate_signal) — activation fees +- [`build_creative`](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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 reference (pointing to a specific data provider signal) 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.0.13/governance/campaign/tasks/check_governance) checks budget authority, it considers all campaigns tied to the plan. When [`report_plan_outcome`](/dist/docs/3.0.13/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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 `governance_agents`, the seller sends `planned_delivery` to the governance agent(s) 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 `governance_agents` URLs 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.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 +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 +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 +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 +{ + "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 +{ + "check_id": "auth_001", + "status": "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 +{ + "check_id": "auth_002", + "status": "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 +{ + "check_id": "auth_004", + "status": "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. + +### Multi-agent composition + +Accounts MAY register multiple governance agents via [`sync_governance`](/dist/docs/3.0.13/accounts/tasks/sync_governance), each responsible for different validation categories. For example, one agent handles budget authority and strategic alignment while another handles regulatory compliance and brand policy. + +When multiple governance agents are registered, the seller MUST call each agent whose `categories` overlap with the action being validated. All applicable agents must approve for the action to proceed (unanimous approval). If any agent returns `denied`, the action is blocked. + +For accounts with a single governance agent, pass a one-element array. + +### 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 MAY be handled by the same agent or by separate agents. The protocol does not prescribe the relationship -- only that the seller can call the `governance_agents` URLs registered on the account. + +## Orchestrator integration pattern + +```mermaid +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.0.13/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. diff --git a/dist/docs/3.0.13/governance/campaign/tasks/check_governance.mdx b/dist/docs/3.0.13/governance/campaign/tasks/check_governance.mdx new file mode 100644 index 0000000000..e073bfd62c --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/tasks/check_governance.mdx @@ -0,0 +1,483 @@ +--- +title: check_governance +description: "check_governance is the universal validation gate in AdCP — orchestrators and sellers call it before executing any campaign action." +"og:title": "AdCP — check_governance" +--- + + +# 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.0.13/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. + +## 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 `governance_agents` (set via [`sync_governance`](/dist/docs/3.0.13/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.0.13/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 status = 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.0.13/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.0.13/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 status is `approved` or `conditions`. A lapsed approval is no approval -- the caller must re-call `check_governance` before proceeding. + +### Conditions + +When the status 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.0.13/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 +{ + "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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "check_id": "chk_001", + "status": "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 +{ + "check_id": "chk_002", + "status": "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 +{ + "check_id": "chk_003", + "status": "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 +{ + "check_id": "chk_004", + "status": "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 +{ + "check_id": "chk_005", + "status": "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 +{ + "check_id": "chk_006", + "status": "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 +{ + "check_id": "chk_007", + "status": "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.0.13/building/by-layer/L1/security#signed-governance-context) for the JWS profile and seller verification; see [Plan binding and audit](/dist/docs/3.0.13/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.0.13/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. | +| `status` | 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 status 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`. | +| `conditions` | array | Present when status 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`). Useful for verifying which validations ran. | +| `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 status 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 status 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.0.13/building/by-layer/L1/security#signed-governance-context) for the JWS profile and seller verification; see [Plan binding and audit](/dist/docs/3.0.13/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 status 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. | +| `AMBIGUOUS_CHECK_TYPE` | correctable | Request contains both intent fields (`tool` + `payload`) and execution fields (`governance_context` + `planned_delivery`). Send one set or the other. | +| `CAMPAIGN_SUSPENDED` | correctable | Campaign governance is suspended pending human review. | +| `SELLER_NOT_RECOGNIZED` | 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 diff --git a/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs.mdx b/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs.mdx new file mode 100644 index 0000000000..45203ced6a --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs.mdx @@ -0,0 +1,281 @@ +--- +title: get_plan_audit_logs +description: "get_plan_audit_logs retrieves governance state, budget tracking, and a complete audit trail for AdCP campaign plans or portfolios." +"og:title": "AdCP — get_plan_audit_logs" +--- + + +# 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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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": 15, + "outcomes_reported": 12, + "statuses": { + "approved": 12, + "denied": 1, + "conditions": 1, + "human_reviewed": 1 // supplementary: subset of approved + denied that went through internal human review + }, + "findings_count": 2, + "human_reviews": [ + { + "check_id": "chk_esc_001", + "reason": "Budget reallocation exceeds threshold", + "resolution": "approved_by_human", + "resolved_at": "2026-03-16T09:30:00Z" + } + ], + "drift_metrics": { + "human_review_rate": 0.07, + "human_review_rate_trend": "stable", + "auto_approval_rate": 0.80, + "human_override_rate": 0.02, + "mean_confidence": 0.88, + "thresholds": { + "human_review_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", + "status": "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", + "status": "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 — a subset of `approved` + `denied` that went through internal human review before resolving. | +| `plans[].summary.findings_count` | number | Total findings across all checks and outcomes. | +| `plans[].summary.human_reviews` | array | Checks that required internal human review and their resolutions. | +| `plans[].summary.human_reviews[].check_id` | string | The governance check that required human review. | +| `plans[].summary.human_reviews[].reason` | string | Why human review was required. | +| `plans[].summary.human_reviews[].resolution` | string | How it was resolved (e.g., `approved_by_human`, `rejected_by_human`). | +| `plans[].summary.human_reviews[].resolved_at` | string | ISO 8601 resolution timestamp. | +| `plans[].summary.drift_metrics` | object | Aggregate governance metrics for detecting oversight drift. See [specification](/dist/docs/3.0.13/governance/campaign/specification#drift-detection). | +| `plans[].summary.drift_metrics.human_review_rate` | number | Fraction of checks that required internal human review (0-1). | +| `plans[].summary.drift_metrics.human_review_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 human reviews where the human overrode the agent (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.human_review_rate_max` | number | Maximum acceptable human review rate. | +| `plans[].summary.drift_metrics.thresholds.human_review_rate_min` | number | Minimum acceptable human review 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`). | +| `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.0.13/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.0.13/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 diff --git a/dist/docs/3.0.13/governance/campaign/tasks/index.mdx b/dist/docs/3.0.13/governance/campaign/tasks/index.mdx new file mode 100644 index 0000000000..410c2d07c1 --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/tasks/index.mdx @@ -0,0 +1,22 @@ +--- +title: Task reference +description: "Task reference for AdCP campaign governance — sync_plans, check_governance, report_plan_outcome, and get_plan_audit_logs." +"og:title": "AdCP — Campaign governance task reference" +sidebarTitle: Overview +--- + + +# 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.0.13/accounts/tasks/sync_governance) syncs governance agent endpoints to accounts. This must be done before sellers can call `check_governance`. diff --git a/dist/docs/3.0.13/governance/campaign/tasks/report_plan_outcome.mdx b/dist/docs/3.0.13/governance/campaign/tasks/report_plan_outcome.mdx new file mode 100644 index 0000000000..af96ac6bf2 --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/tasks/report_plan_outcome.mdx @@ -0,0 +1,242 @@ +--- +title: report_plan_outcome +description: "report_plan_outcome sends action results to the AdCP governance agent so it can update budget tracking, state, and compliance records." +"og:title": "AdCP — report_plan_outcome" +--- + + +# 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.0.13/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 +{ + "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 +{ + "outcome_id": "out_001", + "status": "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 +{ + "outcome_id": "out_002", + "status": "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 +{ + "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 +{ + "outcome_id": "out_del_001", + "status": "accepted" +} +``` + +### Response (anomaly detected) + +```json +{ + "outcome_id": "out_del_002", + "status": "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 +{ + "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 +{ + "outcome_id": "out_003", + "status": "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. | +| `status` | 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 status 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 diff --git a/dist/docs/3.0.13/governance/campaign/tasks/sync_plans.mdx b/dist/docs/3.0.13/governance/campaign/tasks/sync_plans.mdx new file mode 100644 index 0000000000..8d412aa4b8 --- /dev/null +++ b/dist/docs/3.0.13/governance/campaign/tasks/sync_plans.mdx @@ -0,0 +1,238 @@ +--- +title: sync_plans +description: "sync_plans pushes campaign plans with budget limits, channels, flight dates, and compliance policies to an AdCP governance agent." +"og:title": "AdCP — sync_plans" +--- + + +# 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.0.13/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 +{ + "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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 catalog: + +```json +{ + "type": "signal", + "catalog_url": "https://signals.dataprovider.com/catalog.json", + "signal_id": "likely_ev_buyers", + "value": true +} +``` + +**Description** — natural language for constraints that don't map to a specific signal: + +```json +{ + "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.0.13/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_PLAN` | correctable | Plan is missing required fields or has invalid values. | +| `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. | +| `BUDGET_BELOW_COMMITTED` | correctable | Cannot reduce budget below the amount already committed (on plan update). | + +## 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 diff --git a/dist/docs/3.0.13/governance/collection/index.mdx b/dist/docs/3.0.13/governance/collection/index.mdx new file mode 100644 index 0000000000..0508ed2a42 --- /dev/null +++ b/dist/docs/3.0.13/governance/collection/index.mdx @@ -0,0 +1,166 @@ +--- +title: Collection Governance +description: "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." +"og:title": "AdCP — Collection Governance" +"og:image": /images/walkthrough/collection-gov-01-spreadsheet.png +sidebarTitle: Overview +--- + +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.0.13/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 +{ + "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.0.13/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 +{ + "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.0.13/governance/collection/tasks/collection_lists#create_collection_list)**: Create a new collection list on a governance agent +- **[get_collection_list](/dist/docs/3.0.13/governance/collection/tasks/collection_lists#get_collection_list)**: Retrieve resolved collections (with caching guidance) +- **[update_collection_list](/dist/docs/3.0.13/governance/collection/tasks/collection_lists#update_collection_list)**: Modify filters or base collections +- **[list_collection_lists](/dist/docs/3.0.13/governance/collection/tasks/collection_lists#list_collection_lists)**: List collection lists for an account +- **[delete_collection_list](/dist/docs/3.0.13/governance/collection/tasks/collection_lists#delete_collection_list)**: Remove a collection list diff --git a/dist/docs/3.0.13/governance/collection/tasks/collection_lists.mdx b/dist/docs/3.0.13/governance/collection/tasks/collection_lists.mdx new file mode 100644 index 0000000000..804cc05f21 --- /dev/null +++ b/dist/docs/3.0.13/governance/collection/tasks/collection_lists.mdx @@ -0,0 +1,345 @@ +--- +title: Collection List Management +description: "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." +"og:title": "AdCP — Collection List Management" +--- + +# Collection list management + +Collection lists are managed, cacheable artifacts that express "these collections, filtered by these criteria." They parallel [property lists](/dist/docs/3.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/collection/create-collection-list-response.json", + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/collection/get-collection-list-request.json", + "list_id": "cl_novamotors_dna_2026", + "resolve": true, + "pagination": { + "max_results": 1000 + } +} +``` + +**Response:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/collection/get-collection-list-response.json", + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/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.0.13/building/by-layer/L1/security#webhook-security). By default, the RFC 9421 [webhook callbacks profile](/dist/docs/3.0.13/building/by-layer/L1/security#webhook-callbacks) applies: the governance agent signs with its `adcp_use: "webhook-signing"` key published at the `jwks_uri` of its `agents[]` entry in its own brand.json; 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.0.13/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.0.13/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 diff --git a/dist/docs/3.0.13/governance/content-standards/artifacts.mdx b/dist/docs/3.0.13/governance/content-standards/artifacts.mdx new file mode 100644 index 0000000000..b0e1a3ae94 --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/artifacts.mdx @@ -0,0 +1,337 @@ +--- +title: Content Artifacts +description: "Artifacts in AdCP represent the content context adjacent to ad placements, enabling brand suitability evaluation without exposing raw content." +"og:title": "AdCP — Content Artifacts" +sidebarTitle: Artifacts +--- + +# 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.0.13/content-standards/artifact.json) + +Web article: + +```json +{ + "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 +{ + "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 +{ + "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 +// 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 +{"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 +{ + "type": "image", + "url": "https://cdn.example.com/photo.jpg", + "alt_text": "Description of the image" +} +``` + +### Video + +```json +{ + "type": "video", + "url": "https://cdn.example.com/video.mp4", + "transcript": "Full transcript of the video content...", + "duration_ms": 180000 +} +``` + +### Audio + +```json +{ + "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 +{ + "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-configuration isn't possible, include access credentials with individual assets: + +```json +{ + "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` | OAuth2 bearer token in Authorization header | +| `service_account` | GCP/AWS service account credentials | +| `signed_url` | Pre-signed URL with embedded credentials (URL itself is the credential) | + +### Service Account Setup + +For GCP: + +```json +{ + "access": { + "method": "service_account", + "provider": "gcp", + "credentials": { + "type": "service_account", + "project_id": "my-project", + "private_key_id": "...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...", + "client_email": "verification-agent@my-project.iam.gserviceaccount.com" + } + } +} +``` + +For AWS: + +```json +{ + "access": { + "method": "service_account", + "provider": "aws", + "credentials": { + "access_key_id": "AKIAIOSFODNN7EXAMPLE", + "secret_access_key": "...", + "region": "us-east-1" + } + } +} +``` + +### Pre-Signed URLs + +For one-off access without sharing credentials: + +```json +{ + "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 diff --git a/dist/docs/3.0.13/governance/content-standards/implementation-guide.mdx b/dist/docs/3.0.13/governance/content-standards/implementation-guide.mdx new file mode 100644 index 0000000000..250b1af303 --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/implementation-guide.mdx @@ -0,0 +1,424 @@ +--- +title: Content Standards Implementation +sidebarTitle: Implementation Guide +description: "Step-by-step guide to implementing AdCP Content Standards — calibration flow, artifact generation, delivery validation, and integration patterns." +"og:title": "AdCP — Content Standards Implementation" +--- + +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 +{ + "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 +// 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 +// 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 +// 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 +"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 +"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 +{ + "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 +# 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 +// 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 +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 +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 +{ + "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. diff --git a/dist/docs/3.0.13/governance/content-standards/index.mdx b/dist/docs/3.0.13/governance/content-standards/index.mdx new file mode 100644 index 0000000000..080dd9585a --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/index.mdx @@ -0,0 +1,396 @@ +--- +title: Content Standards +description: "AdCP Content Standards enable privacy-preserving brand suitability evaluation for AI-generated and ephemeral content that cannot leave publisher infrastructure." +"og:title": "AdCP — Content Standards" +sidebarTitle: Overview +--- + +# 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 +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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/media-buy/task-reference/update_media_buy) and issue a new [`create_media_buy`](/dist/docs/3.0.13/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 +{ + "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 +// 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 +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.0.13/brand-protocol/brand-json) - Brand identity that can link to standards agents diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/calibrate_content.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/calibrate_content.mdx new file mode 100644 index 0000000000..52a988215f --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/calibrate_content.mdx @@ -0,0 +1,232 @@ +--- +title: calibrate_content +description: "calibrate_content aligns buyers and sellers on brand suitability standards through structured dialogue before AdCP campaign execution begins." +"og:title": "AdCP — calibrate_content" +--- + +# 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.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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.0.13/content-standards/calibrate-content-response.json) + +### Passing Response + +```json +{ + "$schema": "/schemas/3.0.13/content-standards/calibrate-content-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/content-standards/calibrate-content-response.json", + "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 +// 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 +// 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 diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/create_content_standards.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/create_content_standards.mdx new file mode 100644 index 0000000000..fc1dd59ae6 --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/create_content_standards.mdx @@ -0,0 +1,126 @@ +--- +title: create_content_standards +description: "create_content_standards defines a brand suitability configuration with content policies, risk thresholds, and category rules for AdCP campaigns." +"og:title": "AdCP — create_content_standards" +--- + +# create_content_standards + +Create a new content standards configuration. + +**Response time**: < 1s + +## Request + +**Schema**: [create-content-standards-request.json](https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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.0.13/content-standards/create-content-standards-response.json) + +### Success Response + +```json +{ + "$schema": "/schemas/3.0.13/content-standards/create-content-standards-response.json", + "standards_id": "emea_digital_safety" +} +``` + +### Error Responses + +**Scope Conflict:** + +```json +{ + "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.0.13/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.0.13/governance/content-standards/index#calibration) for the full workflow. + diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/get_content_standards.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/get_content_standards.mdx new file mode 100644 index 0000000000..e082dbc85d --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/get_content_standards.mdx @@ -0,0 +1,88 @@ +--- +title: get_content_standards +description: "get_content_standards retrieves the full content safety policy configuration for a specific standards ID in AdCP." +"og:title": "AdCP — get_content_standards" +--- + +# get_content_standards + +Retrieve content safety policies for a specific standards configuration. + +## Request + +**Schema**: [get-content-standards-request.json](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/content-standards/get-content-standards-response.json) + +### Success Response + +```json +{ + "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 +{ + "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 diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/get_media_buy_artifacts.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/get_media_buy_artifacts.mdx new file mode 100644 index 0000000000..e37577e99f --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/get_media_buy_artifacts.mdx @@ -0,0 +1,285 @@ +--- +title: get_media_buy_artifacts +description: "get_media_buy_artifacts retrieves content context records from a media buy for post-delivery brand suitability validation in AdCP." +"og:title": "AdCP — get_media_buy_artifacts" +--- + +# 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 +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.0.13/content-standards/get-media-buy-artifacts-request.json) + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `account` | [account-ref](/dist/docs/3.0.13/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.0.13/content-standards/get-media-buy-artifacts-response.json) + +### Success Response + +```json +{ + "$schema": "/schemas/3.0.13/content-standards/get-media-buy-artifacts-response.json", + "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 +# 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 +# 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.0.13/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 +{ + "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 +{ + "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 +{ + "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 diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/list_content_standards.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/list_content_standards.mdx new file mode 100644 index 0000000000..68a070820f --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/list_content_standards.mdx @@ -0,0 +1,70 @@ +--- +title: list_content_standards +description: "list_content_standards returns available content safety configurations with optional filtering and pagination in AdCP." +"og:title": "AdCP — list_content_standards" +--- + +# list_content_standards + +List available content standards configurations. + +**Response time**: < 500ms + +## Request + +**Schema**: [list-content-standards-request.json](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/content-standards/list-content-standards-response.json", + "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 +{ + "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 diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/update_content_standards.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/update_content_standards.mdx new file mode 100644 index 0000000000..7aa0c44f3c --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/update_content_standards.mdx @@ -0,0 +1,104 @@ +--- +title: update_content_standards +description: "update_content_standards modifies an existing content standards configuration in AdCP, creating a new version for audit purposes." +"og:title": "AdCP — update_content_standards" +--- + +# 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.0.13/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 +{ + "$schema": "/schemas/3.0.13/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.0.13/content-standards/update-content-standards-response.json) + +### Success Response + +```json +{ + "$schema": "/schemas/3.0.13/content-standards/update-content-standards-response.json", + "success": true, + "standards_id": "nike_emea_safety" +} +``` + +### Error Response + +```json +{ + "$schema": "/schemas/3.0.13/content-standards/update-content-standards-response.json", + "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 diff --git a/dist/docs/3.0.13/governance/content-standards/tasks/validate_content_delivery.mdx b/dist/docs/3.0.13/governance/content-standards/tasks/validate_content_delivery.mdx new file mode 100644 index 0000000000..b5b513e5a2 --- /dev/null +++ b/dist/docs/3.0.13/governance/content-standards/tasks/validate_content_delivery.mdx @@ -0,0 +1,184 @@ +--- +title: validate_content_delivery +description: "validate_content_delivery batch-evaluates delivery records against content safety policies asynchronously in AdCP." +"og:title": "AdCP — validate_content_delivery" +--- + +# 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 +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.0.13/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 +{ + "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.0.13/content-standards/validate-content-delivery-response.json) + +### Success Response + +```json +{ + "$schema": "/schemas/3.0.13/content-standards/validate-content-delivery-response.json", + "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 +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 +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 +# 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 diff --git a/dist/docs/3.0.13/governance/creative/get_creative_features.mdx b/dist/docs/3.0.13/governance/creative/get_creative_features.mdx new file mode 100644 index 0000000000..bdc7f13950 --- /dev/null +++ b/dist/docs/3.0.13/governance/creative/get_creative_features.mdx @@ -0,0 +1,175 @@ +--- +title: get_creative_features +description: "get_creative_features evaluates a creative manifest against a governance agent and returns feature values for brand safety and compliance in AdCP." +"og:title": "AdCP — get_creative_features" +--- + +# get_creative_features + + +**AdCP 3.0 Proposal** - This task is under development for AdCP 3.0. + + +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 +{ + "$schema": "/schemas/3.0.13/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. | + +## Response + + +```json Security scanner (clean) +{ + "$schema": "/schemas/3.0.13/creative/get-creative-features-response.json", + "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) +{ + "$schema": "/schemas/3.0.13/creative/get-creative-features-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/creative/get-creative-features-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/creative/get-creative-features-response.json", + "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" +} +``` + + +### 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. | + +### Error response + +```json +{ + "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.0.13/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 +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; +} +``` + +## 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` | diff --git a/dist/docs/3.0.13/governance/creative/index.mdx b/dist/docs/3.0.13/governance/creative/index.mdx new file mode 100644 index 0000000000..94de44768a --- /dev/null +++ b/dist/docs/3.0.13/governance/creative/index.mdx @@ -0,0 +1,212 @@ +--- +title: Creative Governance +description: "AdCP Creative Governance provides standardized creative evaluation — security scanning, AI provenance verification, and accessibility compliance checks." +"og:title": "AdCP — Creative Governance" +sidebarTitle: Overview +--- + + +**AdCP 3.0 Proposal** - This protocol is under development for AdCP 3.0. Feedback welcome via [GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions). + + +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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +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.0.13/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.0.13/creative/specification#pricing), [signals agents](/dist/docs/3.0.13/signals/specification), and [content standards](/dist/docs/3.0.13/governance/content-standards/index). + +Governance agents that charge MUST implement the [Accounts Protocol](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/async-operations) handles this. diff --git a/dist/docs/3.0.13/governance/creative/provenance-verification.mdx b/dist/docs/3.0.13/governance/creative/provenance-verification.mdx new file mode 100644 index 0000000000..afaade7c09 --- /dev/null +++ b/dist/docs/3.0.13/governance/creative/provenance-verification.mdx @@ -0,0 +1,437 @@ +--- +title: Provenance verification +description: "How AI provenance claims travel with creatives in AdCP and get independently verified at each enforcement point in the delivery chain." +"og:title": "AdCP — Provenance verification" +sidebarTitle: Provenance verification +--- + +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 +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. + +## 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 +{ + "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 +{ + "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 +{ + "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 +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' +); + +// 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 in jurisdiction requiring disclosure +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 +{ + "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 +{ + "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 +{ + "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) +{ + "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) +{ + "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 +{ + "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**: Requires that AI-generated content be labeled in a machine-readable way. AdCP's `digital_source_type` field provides this classification at the asset level. The `disclosure.jurisdictions` array allows creatives to carry jurisdiction-specific label text. 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`). + +**California SB 942**: Requires disclosure when content is generated or substantially modified by AI. The `digital_source_type` and `human_oversight` fields together provide the information needed to determine whether a creative meets the disclosure threshold. The `disclosure.required` flag provides a direct signal for enforcement. + +**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 +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 | +| 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 | +| Run AI detection | Call `get_creative_features` with an AI detection agent to verify provenance claims | +| Compare claims vs detection | Implement logic to compare `digital_source_type` against `ai_generated` feature results | +| Enforce disclosure | Verify that AI content includes appropriate `disclosure` metadata for target jurisdictions | +| Reject mismatches | Reject creatives where provenance claims contradict detection results | +| 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 | +| Provide detail URLs | Link to full reports via `detail_url` for audit trails | + +## Related + +- [AI provenance and disclosure](/dist/docs/3.0.13/creative/provenance) -- The provenance schema reference, digital source type enum, and inheritance model +- [Creative Governance](/dist/docs/3.0.13/governance/creative/index) -- Feature-based creative evaluation via `get_creative_features` +- [`get_creative_features`](/dist/docs/3.0.13/governance/creative/get_creative_features) -- Task reference for creative feature evaluation +- [Content Standards](/dist/docs/3.0.13/governance/content-standards/index) -- Privacy-preserving brand suitability for publisher content +- [`calibrate_content`](/dist/docs/3.0.13/governance/content-standards/tasks/calibrate_content) -- Calibration task for content standards alignment +- [`validate_content_delivery`](/dist/docs/3.0.13/governance/content-standards/tasks/validate_content_delivery) -- Post-delivery content validation diff --git a/dist/docs/3.0.13/governance/embedded-human-judgment.mdx b/dist/docs/3.0.13/governance/embedded-human-judgment.mdx new file mode 100644 index 0000000000..a244f697ed --- /dev/null +++ b/dist/docs/3.0.13/governance/embedded-human-judgment.mdx @@ -0,0 +1,550 @@ +--- +title: Embedded human judgment +sidebarTitle: Embedded human judgment +"og:image": /images/concepts/three-party-governance.png +description: "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." +"og:title": "AdCP — Embedded human judgment" +--- + +*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.0.13/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.0.13/brand-protocol/brand-json) and [`adagents.json`](/dist/docs/3.0.13/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.0.13/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.0.13/brand-protocol/brand-json) (the brand itself or its operator). +- **Actor owed payment for the decision** — declared in [`adagents.json`](/dist/docs/3.0.13/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.0.13/governance/campaign/tasks/sync_plans) — `budget.reallocation_threshold`, `plan.human_review_required` | +| Governance invocation on every spend-commit | [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance) — called by orchestrator (intent check) and seller (execution check) | +| Three-party separation of duties | [Safety model](/dist/docs/3.0.13/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.0.13/governance/campaign/tasks/get_plan_audit_logs) | +| Regulatory policy registry | [Policy Registry](/dist/docs/3.0.13/governance/policy-registry) | + +## Policy Registry + +The [Policy Registry](/dist/docs/3.0.13/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 + + diff --git a/dist/docs/3.0.13/governance/overview.mdx b/dist/docs/3.0.13/governance/overview.mdx new file mode 100644 index 0000000000..c4e1c37623 --- /dev/null +++ b/dist/docs/3.0.13/governance/overview.mdx @@ -0,0 +1,304 @@ +--- +title: Governance protocol +sidebarTitle: Overview +"og:image": /images/walkthrough/governance-01-no-oversight.png +description: "AdCP governance ensures human oversight of autonomous AI advertising through three-party validation, budget controls, and brand safety enforcement." +"og:title": "AdCP — Governance protocol" +--- + +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.0.13/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.0.13/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.0.13/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 +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 +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 +{ + "check_id": "chk-q2-ctv-001", + "status": "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.0.13/governance/content-standards/index#versioning-and-mid-flight-amendments). + +First, the orchestrator reports that the seller accepted the purchase and committed budget: + +```javascript +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 +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 +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.0.13/governance/embedded-human-judgment) — the framework that ensures humans remain accountable when AI agents operate autonomously. [Read the five principles →](/dist/docs/3.0.13/governance/embedded-human-judgment) + + +## Protocol domains + +The Governance Protocol covers five 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.0.13/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.0.13/governance/content-standards/index) and [brand identity](/dist/docs/3.0.13/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.0.13/governance/campaign/safety-model) — separation of duties, delegation, and escalation patterns +- **Campaign specification**: [Full data model](/dist/docs/3.0.13/governance/campaign/specification) — plans, checks, outcomes, and policy resolution +- **Content standards**: [Brand suitability](/dist/docs/3.0.13/governance/content-standards/index) — privacy-preserving calibration for content evaluation +- **Property governance**: [Where ads can run](/dist/docs/3.0.13/governance/property/index) — property lists, adagents.json, and publisher authorization +- **Collection governance**: [What content ads run in](/dist/docs/3.0.13/governance/collection/index) — collection lists for program-level brand safety across platforms +- **Policy registry**: [Community policies](/dist/docs/3.0.13/governance/policy-registry) — standardized regulations and brand safety policies +- **Get certified**: [Specialist governance modules](/dist/docs/3.0.13/learning/specialist/governance) teach the full governance system through interactive scenarios diff --git a/dist/docs/3.0.13/governance/policy-attribution.mdx b/dist/docs/3.0.13/governance/policy-attribution.mdx new file mode 100644 index 0000000000..046e835a3c --- /dev/null +++ b/dist/docs/3.0.13/governance/policy-attribution.mdx @@ -0,0 +1,163 @@ +--- +title: Policy Attribution +description: "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." +"og:title": "AdCP — Policy Attribution" +sidebarTitle: Policy Attribution +--- + +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.0.13/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.0.13/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.0.13/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 +{ + "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.0.13/property/property-feature-definition.json) and [Policy Registry](/dist/docs/3.0.13/governance/policy-registry#feature-prefix-convention)) where the feature ID `registry:` references a standardized policy directly: + +```json +{ + "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 +{ + "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 +{ + "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.0.13/governance/policy-registry) — the shared library of `policy_id`s +- [Policy Registry Sync](/dist/docs/3.0.13/governance/policy-registry-sync) — how plans reference policies via `policy_ids[]` and `custom_policies[]` +- [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance) — where findings are emitted with `policy_id` traceability diff --git a/dist/docs/3.0.13/governance/policy-registry-sync.mdx b/dist/docs/3.0.13/governance/policy-registry-sync.mdx new file mode 100644 index 0000000000..d45e52ad47 --- /dev/null +++ b/dist/docs/3.0.13/governance/policy-registry-sync.mdx @@ -0,0 +1,113 @@ +--- +title: "Policy Registry: Sync and versioning" +sidebarTitle: Sync and versioning +description: "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." +"og:title": "AdCP — Policy Registry sync and versioning" +--- + +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 +{ + "plan_id": "plan_q1_2027_acme", + "policy_ids": ["us_coppa", "alcohol_advertising"], + "policy_categories": ["age_restricted"] +} +``` + +At every [`check_governance`](/dist/docs/3.0.13/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.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/governance/policy-registry) — registry concepts, policy structure, seeded policies, restricted attributes +- [Campaign Governance specification](/dist/docs/3.0.13/governance/campaign/specification) — plan binding, `plan_hash`, governance context lifecycle +- [audit trail: internal vs shareable views](/dist/docs/3.0.13/governance/campaign/audit-trail) — how to surface evaluation history to counterparties +- [`sync_plans`](/dist/docs/3.0.13/governance/campaign/tasks/sync_plans) — `policy_ids`, `policy_categories`, `custom_policies` +- [Annex III & Art 22 obligations](/dist/docs/3.0.13/governance/annex-iii-obligations) — when human review is required diff --git a/dist/docs/3.0.13/governance/policy-registry.mdx b/dist/docs/3.0.13/governance/policy-registry.mdx new file mode 100644 index 0000000000..414bc71427 --- /dev/null +++ b/dist/docs/3.0.13/governance/policy-registry.mdx @@ -0,0 +1,359 @@ +--- +title: Policy Registry +description: "The AdCP Policy Registry is a shared library of machine-readable compliance policies that governance agents reference by ID during campaign validation." +"og:title": "AdCP — Policy Registry" +sidebarTitle: Policy Registry +--- + + +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 +curl https://adcontextprotocol.org/api/policies/resolve?policy_id=us_coppa +``` + +List all regulation-category policies: + +```bash +curl https://adcontextprotocol.org/api/policies/registry?category=regulation +``` + +Bulk-resolve policies for an LLM evaluation prompt: + +```bash +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.0.13/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.0.13/governance/policy-entry.json): + +```json +{ + "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.0.13/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.0.13/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.0.13/governance/campaign/index)** | Resolves policies via brand compliance config, evaluates actions against policy text in `check_governance` | +| **[Content Standards](/dist/docs/3.0.13/governance/content-standards/index)** | Creates content standards from registry policies using `registry_policy_ids` | +| **[Property Governance](/dist/docs/3.0.13/governance/property/index)** | Declares `registry:` prefixed features in `get_adcp_capabilities`, evaluates properties against policy text | +| **[Creative Governance](/dist/docs/3.0.13/governance/creative/index)** | Declares `registry:` prefixed creative features, evaluates creatives for AI disclosure and content compliance | +| **[Media Buy](/dist/docs/3.0.13/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. + +**Property governance agent declares:** +```json +{ + "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 +{ + "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 +{ + "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 +{ + "tool": "get_products", + "arguments": { + "brief": "UK video inventory for Q1", + "required_policies": ["uk_hfss", "eu_gdpr_advertising"] + } +} +``` + +**Seller declares enforcement:** +```json +{ + "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.0.13/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.0.13/signals/data-providers#declaring-governance-metadata). Governance agents match signal-declared attributes against plan-level `restricted_attributes` during [`check_governance`](/dist/docs/3.0.13/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 diff --git a/dist/docs/3.0.13/governance/property/adagents.mdx b/dist/docs/3.0.13/governance/property/adagents.mdx new file mode 100644 index 0000000000..722d30b309 --- /dev/null +++ b/dist/docs/3.0.13/governance/property/adagents.mdx @@ -0,0 +1,1017 @@ +--- +title: adagents.json Tech Spec +description: "adagents.json is the publisher-hosted file in AdCP that declares advertising properties and authorizes sales agents to sell inventory." +"og:title": "AdCP — adagents.json Tech Spec" +--- + +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 +{ + "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 signal catalogs (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.0.13/signals/data-providers) for the signals-side documentation. + + +**[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.0.13/brand-protocol/brand-json) using the `relationship` field, which 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" | + +Both sides must agree — and the `delegation_type` and `relationship` values should match. See [ad networks](/dist/docs/3.0.13/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`). The values are the same: `direct`, `delegated`, `ad_network`. + + +## 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. + +## Basic Structure + +The file must be valid JSON with UTF-8 encoding and return HTTP 200 status. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 + +**`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.0.13/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` for grouping, `property_ids` or `property_tags` for property linkage, and optional `format_ids` for canonical format support + - 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 + +**`authorized_agents`** *(required)*: Array of authorized sales agents + - **`url`** *(required)*: Agent's API endpoint URL + - **`authorized_for`** *(required)*: Human-readable authorization description + - **`authorization_type`** *(optional)*: One of `property_ids`, `property_tags`, `inline_properties`, `publisher_properties` + - **`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 + - **Additional fields**: Depends on authorization_type (see patterns below) + +**`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. + +## 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/governance/property/managed-networks). + +### 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/governance/property/managed-networks#security-considerations). That section is the canonical source for blast-radius guidance on the authoritative_location pattern. + +### 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.0.13/governance/property/managed-networks#security-considerations) — the requirements there take precedence over any `Cache-Control` on the origin. + +## Authorization Patterns + +AdCP supports four authorization patterns, each optimized for different use cases: + +### Pattern 1: Property IDs (Direct References) + +**Best for**: Specific, enumerable property lists. Direct and unambiguous. + +**Structure**: +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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`). + +## Authorization Qualifiers + +The four `authorization_type` patterns above answer **which inventory** an agent can sell. 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." + +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_ids` to answer "what formats does this placement support?" without relying entirely on product-local placement definitions + +### `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.0.13/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 +{ + "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_ids": [ + {"agent_url": "https://creative.example.com", "id": "audio_15s"} + ] + }, + { + "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_ids": [ + {"agent_url": "https://creative.example.com", "id": "audio_60s"} + ] + } + ], + "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 declare their own `placements`, but they SHOULD reuse the publisher's registered `placement_id` values when the placement is part of that registry. Reusing a placement ID 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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +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.0.13/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 +// 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 +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 +# 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 +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 +// Using the @adcp/client PropertyCrawler for discovery +import { PropertyCrawler } from '@adcp/client'; + +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 +# 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 + +## Next Steps + +After implementing adagents.json validation: + +1. **Integrate with Product Discovery**: Use [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) to discover inventory +2. **Validate at Purchase**: Check authorization before calling [`create_media_buy`](/dist/docs/3.0.13/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.0.13/protocol/get_adcp_capabilities) - Discover agent capabilities and portfolio +- [Property Schema](https://adcontextprotocol.org/schemas/3.0.13/core/property.json) - Property definition structure +- [AdAgents.json Builder](https://agenticadvertising.org/adagents/builder) - Web-based validator and creator diff --git a/dist/docs/3.0.13/governance/property/authorized-properties.mdx b/dist/docs/3.0.13/governance/property/authorized-properties.mdx new file mode 100644 index 0000000000..6d2681c6db --- /dev/null +++ b/dist/docs/3.0.13/governance/property/authorized-properties.mdx @@ -0,0 +1,280 @@ +--- +title: Understanding Authorization +description: "Authorized properties in AdCP let buyers restrict campaigns to verified inventory using property lists and supply path validation." +"og:title": "AdCP — Understanding Authorization" +--- + + +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, using the same values as `delegation_type` +3. **Agent Discovery**: Sales agents declare their portfolio via [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities) in the `media_buy.portfolio` section + +The first two create bilateral verification — like `ads.txt` + `sellers.json` in programmatic. Both sides must agree for the supply path to be trusted. See [ad networks](/dist/docs/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 +{ + "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 +// 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 +// 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.0.13/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.0.13/core/property.json)** - Technical property data model +- **[adagents.json Tech Spec](./adagents)** - Complete `adagents.json` implementation guide diff --git a/dist/docs/3.0.13/governance/property/index.mdx b/dist/docs/3.0.13/governance/property/index.mdx new file mode 100644 index 0000000000..9ce6e00edc --- /dev/null +++ b/dist/docs/3.0.13/governance/property/index.mdx @@ -0,0 +1,285 @@ +--- +title: Property Governance +description: "AdCP Property Governance standardizes identification, authorization, enrichment, and selection of advertising properties across all media channels." +"og:title": "AdCP — Property Governance" +sidebarTitle: Overview +--- + +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.0.13/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.0.13/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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/protocol/get_adcp_capabilities) in the `governance.property_features` section: + +```json +{ + "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 +{ + "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 +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 +{ + "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 +{ + "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.0.13/protocol/get_adcp_capabilities)**: Discover governance capabilities including property features (protocol-level task) + +### Property List Management + +- **[create_property_list](/dist/docs/3.0.13/governance/property/tasks/property_lists#create_property_list)**: Create a new property list on a governance agent +- **[get_property_list](/dist/docs/3.0.13/governance/property/tasks/property_lists#get_property_list)**: Retrieve resolved properties (with caching guidance) +- **[update_property_list](/dist/docs/3.0.13/governance/property/tasks/property_lists#update_property_list)**: Modify filters or base properties +- **[delete_property_list](/dist/docs/3.0.13/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.0.13/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.0.13/governance/property/specification) for implementation details + +See the [Protocol Specification](/dist/docs/3.0.13/governance/property/specification) for detailed implementation guidance. diff --git a/dist/docs/3.0.13/governance/property/managed-networks.mdx b/dist/docs/3.0.13/governance/property/managed-networks.mdx new file mode 100644 index 0000000000..94a5db9ce1 --- /dev/null +++ b/dist/docs/3.0.13/governance/property/managed-networks.mdx @@ -0,0 +1,629 @@ +--- +title: Managed Network Deployment +description: "How managed publisher networks deploy adagents.json across thousands of domains using the URL reference pattern, delegation types, and common infrastructure approaches." +"og:title": "AdCP — Managed Network Deployment Guide" +--- + +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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +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. + +HTTP redirects to `/.well-known/adagents.json` are not prohibited, but they are not the recommended pattern. Use `authoritative_location` instead. + +## 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 +{ + "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 +{ + "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. + +## 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 +{ + "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 +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.0.13/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 +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.0.13/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 + 'https://adcontextprotocol.org/schemas/3.0.13/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 +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 +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 +# 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 +# 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 (recommended: reject > 5 MB) and enforce short connect/read timeouts (≤ 10s each). +- 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. + +**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.0.13/governance/property/adagents) — full schema reference, authorization patterns, and validation behavior +- [Property Governance overview](/dist/docs/3.0.13/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/client](https://github.com/adcontextprotocol/adcp-client) — TypeScript client library with network consistency checking diff --git a/dist/docs/3.0.13/governance/property/specification.mdx b/dist/docs/3.0.13/governance/property/specification.mdx new file mode 100644 index 0000000000..d617af8b23 --- /dev/null +++ b/dist/docs/3.0.13/governance/property/specification.mdx @@ -0,0 +1,637 @@ +--- +title: Property Governance Specification +description: "Formal specification for AdCP property governance — property models, feature evaluation, list management, and delivery validation." +"og:title": "AdCP — Property Governance Specification" +sidebarTitle: Specification +--- + +**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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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.0.13/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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +# 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 +{ + "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 +{ + "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 +{ + "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.0.13/governance/property/adagents) for property declaration and authorization +- See the [get_adcp_capabilities task reference](/dist/docs/3.0.13/protocol/get_adcp_capabilities) for capability discovery +- See the [Property List Management](/dist/docs/3.0.13/governance/property/tasks/property_lists) for CRUD operations and webhooks +- See the [validate_property_delivery task reference](/dist/docs/3.0.13/governance/property/tasks/validate_property_delivery) for post-campaign compliance validation diff --git a/dist/docs/3.0.13/governance/property/tasks/index.mdx b/dist/docs/3.0.13/governance/property/tasks/index.mdx new file mode 100644 index 0000000000..a77f8f7e02 --- /dev/null +++ b/dist/docs/3.0.13/governance/property/tasks/index.mdx @@ -0,0 +1,151 @@ +--- +title: Property Governance Tasks +description: "Task reference for AdCP property governance — property list management, feature evaluation, and delivery validation tasks." +"og:title": "AdCP — Property Governance Tasks" +sidebarTitle: Task Reference +--- + +# 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.0.13/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.0.13/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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +# 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 +``` diff --git a/dist/docs/3.0.13/governance/property/tasks/property_lists.mdx b/dist/docs/3.0.13/governance/property/tasks/property_lists.mdx new file mode 100644 index 0000000000..a54788b40d --- /dev/null +++ b/dist/docs/3.0.13/governance/property/tasks/property_lists.mdx @@ -0,0 +1,699 @@ +--- +title: Property List Management +description: "Property list tasks in AdCP create, update, get, list, and delete inclusion and exclusion lists combining static sets with dynamic filters." +"og:title": "AdCP — Property List Management" +--- + +**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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ "feature_id": "mfa_score", "min_value": 85, "max_value": 100 } +``` + +For **binary** features (true/false): +```json +{ "feature_id": "coppa_certified", "allowed_values": [true] } +``` + +For **categorical** features (enum values): +```json +{ "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 +{ + "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 +{ + "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 +{ + "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.0.13/property/base-property-source.json) for the full specification. + +--- + +## create_property_list + +Create a new property list. + +### Request + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "tool": "update_property_list", + "arguments": { + "list_id": "pl_abc123", + "filters": { + "exclude_identifiers": [ + { "type": "domain", "value": "excluded-site.com" } + ] + } + } +} +``` + +### Response + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "tool": "get_property_list", + "arguments": { + "list_id": "pl_abc123", + "resolve": false + } +} +``` + +### Response (Metadata Only) + +```json +{ + "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 +{ + "tool": "list_property_lists", + "arguments": { + "name_contains": "UK", + "pagination": { + "max_results": 50 + } + } +} +``` + +### Response + +```json +{ + "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 +{ + "tool": "delete_property_list", + "arguments": { + "list_id": "pl_abc123" + } +} +``` + +### Response + +```json +{ + "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 +{ + "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 +{ + "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.0.13/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 +# 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 diff --git a/dist/docs/3.0.13/governance/property/tasks/validate_property_delivery.mdx b/dist/docs/3.0.13/governance/property/tasks/validate_property_delivery.mdx new file mode 100644 index 0000000000..e229a7b52f --- /dev/null +++ b/dist/docs/3.0.13/governance/property/tasks/validate_property_delivery.mdx @@ -0,0 +1,401 @@ +--- +title: validate_property_delivery +description: "validate_property_delivery checks delivery records against a property list for compliance and supply path authorization in AdCP." +"og:title": "AdCP — validate_property_delivery" +--- + +# 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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/property/validate-property-delivery-response.json", + "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 +"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 +# 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 +{ + "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 +{ + "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 +{ + "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 +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 +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 +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.0.13/protocol/get_adcp_capabilities) - Discover available filter features diff --git a/dist/docs/3.0.13/governance/rfc-process.mdx b/dist/docs/3.0.13/governance/rfc-process.mdx new file mode 100644 index 0000000000..01dc247dd3 --- /dev/null +++ b/dist/docs/3.0.13/governance/rfc-process.mdx @@ -0,0 +1,111 @@ +--- +title: RFC process +description: "How to propose and ratify material changes to AdCP — the lifecycle from draft to specification change, including proposal template and decision-record format." +"og:title": "AdCP — RFC process" +--- + +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.0.13/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 +## 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 +## 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.0.13/governance/overview) — the three-party model and campaign governance domains +- [Embedded human judgment](/dist/docs/3.0.13/governance/embedded-human-judgment) — the principle behind the governance system that most RFCs serve diff --git a/dist/docs/3.0.13/governance/working-group-charter.mdx b/dist/docs/3.0.13/governance/working-group-charter.mdx new file mode 100644 index 0000000000..195916f262 --- /dev/null +++ b/dist/docs/3.0.13/governance/working-group-charter.mdx @@ -0,0 +1,92 @@ +--- +title: Working group charter +sidebarTitle: Working group charter +description: "Operational charter for the AdCP Working Group: quorum, voting thresholds, cadence, recusal, and escalation." +"og:title": "AdCP — Working group charter" +--- + +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/tmp/`, `static/schemas/source/sponsored-intelligence/`, or `static/schemas/source/a2ui/` — receive a downgraded decision class consistent with the [experimental surface policy](/dist/docs/3.0.13/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. diff --git a/dist/docs/3.0.13/images/guides/creator-economy/content-commerce-creative.png b/dist/docs/3.0.13/images/guides/creator-economy/content-commerce-creative.png new file mode 100644 index 0000000000..410359d55b Binary files /dev/null and b/dist/docs/3.0.13/images/guides/creator-economy/content-commerce-creative.png differ diff --git a/dist/docs/3.0.13/images/guides/creator-economy/convergence.png b/dist/docs/3.0.13/images/guides/creator-economy/convergence.png new file mode 100644 index 0000000000..37fe33b62e Binary files /dev/null and b/dist/docs/3.0.13/images/guides/creator-economy/convergence.png differ diff --git a/dist/docs/3.0.13/images/guides/creator-economy/episode-products.png b/dist/docs/3.0.13/images/guides/creator-economy/episode-products.png new file mode 100644 index 0000000000..921c909a39 Binary files /dev/null and b/dist/docs/3.0.13/images/guides/creator-economy/episode-products.png differ diff --git a/dist/docs/3.0.13/images/guides/creator-economy/play-button-progression.png b/dist/docs/3.0.13/images/guides/creator-economy/play-button-progression.png new file mode 100644 index 0000000000..03914adb64 Binary files /dev/null and b/dist/docs/3.0.13/images/guides/creator-economy/play-button-progression.png differ diff --git a/dist/docs/3.0.13/images/guides/creator-economy/three-stages.png b/dist/docs/3.0.13/images/guides/creator-economy/three-stages.png new file mode 100644 index 0000000000..b6db948e2b Binary files /dev/null and b/dist/docs/3.0.13/images/guides/creator-economy/three-stages.png differ diff --git a/dist/docs/3.0.13/images/pr-screenshots/event-speakers-admin-modal.png b/dist/docs/3.0.13/images/pr-screenshots/event-speakers-admin-modal.png new file mode 100644 index 0000000000..d1fdc85954 Binary files /dev/null and b/dist/docs/3.0.13/images/pr-screenshots/event-speakers-admin-modal.png differ diff --git a/dist/docs/3.0.13/intro.mdx b/dist/docs/3.0.13/intro.mdx new file mode 100644 index 0000000000..bce6b94f38 --- /dev/null +++ b/dist/docs/3.0.13/intro.mdx @@ -0,0 +1,713 @@ +--- +title: Introduction to AdCP +sidebarTitle: Introduction +"og:image": /images/walkthrough/adcp-01-fragmentation.png +"og:title": "AdCP — Introduction to the Protocol" +description: "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. + +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, capabilities, and authorized agents. Alex's buyer agent reads it the same way a browser reads `robots.txt`. + +``` +https://streamhaus.tv/.well-known/adagents.json +``` + +```json +{ + "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.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 the JWKS it publishes in `adagents.json`, 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, keys published in its own `agents[]` entry under `adcp_use: "webhook-signing"`. 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.0.13/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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/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 your adagents.json** — Declare your properties and authorized agents so buyers can discover you. +3. **Test your implementation** — Validate with [Addie](https://adcontextprotocol.org/chat) or the [client SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas). + +Implement the protocol domains relevant to your business: +- **Publishers and SSPs**: [Media Buy](/dist/docs/3.0.13/media-buy) and [adagents.json](/dist/docs/3.0.13/governance/property) +- **Data providers**: [Signals](/dist/docs/3.0.13/signals/overview) and [data provider guide](/dist/docs/3.0.13/signals/data-providers) +- **Creative platforms**: [Creative](/dist/docs/3.0.13/creative) +- **Governance vendors**: [Governance protocol](/dist/docs/3.0.13/governance/overview) +- **Brands**: [Brand Protocol](/dist/docs/3.0.13/brand-protocol) and [brand.json](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building) and [client SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas) with your technical team to start integrating. + +### Client libraries + + +```bash JavaScript/TypeScript +npm install @adcp/client +``` +```bash Python +pip install adcp +``` +```bash Go +go get github.com/adcontextprotocol/adcp-go/adcp +``` + + +- **NPM**: [@adcp/client](https://www.npmjs.com/package/@adcp/client) | [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.0.13/building/by-layer/L0/schemas) and [skill files](/dist/docs/3.0.13/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 diff --git a/dist/docs/3.0.13/learning/failure-mode-scope.mdx b/dist/docs/3.0.13/learning/failure-mode-scope.mdx new file mode 100644 index 0000000000..7bf3c2c2b4 --- /dev/null +++ b/dist/docs/3.0.13/learning/failure-mode-scope.mdx @@ -0,0 +1,218 @@ +--- +title: "Failure-mode competency scope" +sidebarTitle: "Failure modes" +description: Curriculum scoping document — which failure modes belong in which certification modules, depth target, and assessment approach. Authoring is a follow-up issue per module. +"og:title": "AdCP — Failure-mode competency scope" +--- + +# Failure-mode competency scope + +This document maps each AdCP failure-mode scenario to the certification module(s) where it belongs, specifies the depth target per tier, and defines the assessment approach. It is a scoping artifact — authoring of the actual content in each module is tracked in follow-up issues per module. + +**Depth levels used below:** + +| Level | Meaning | +|---|---| +| Surface / recognize | Knows the failure mode exists; can name it when prompted | +| Diagnose / explain | Can describe the cause, the affected protocol surface, and the correct recovery path | +| Resolve / demonstrate | Can execute recovery hands-on using protocol tools against a sandbox agent | +| Evaluate / create | Can reason about multi-domain conflicts, adjudicate competing rules, and construct novel scenarios | + +--- + +## FM-1 — Idempotency replay / conflict / expired + +**Status:** Partially covered. `#2346` and `#2367` established idempotency in the training agent. This entry consolidates the assessment scope. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S1** (Media buy) | Resolve / demonstrate | Spot-the-error: three-call transcript — (1) retry with same key + same payload → `replayed: true`; (2) re-plan with new payload + same key → `IDEMPOTENCY_CONFLICT`; (3) key used after TTL → `IDEMPOTENCY_EXPIRED`. Learner identifies which caller violated the contract and explains correct behavior for all three. | +| A2 | Surface / recognize | Learner explains that `idempotency_key` makes agent retries safe for real money — no deeper troubleshooting required. | +| C4 (Build project) | Resolve / demonstrate | Learner's submitted agent generates idempotency keys correctly and handles retries without re-planning. | + +**Gap to close before authoring:** Confirm S1 lab exercise 7 ("Lifecycle management") stages all three error states in sandbox, not just `NOT_CANCELLABLE`. If not, extend exercise 7 to cover `IDEMPOTENCY_CONFLICT` and `IDEMPOTENCY_EXPIRED`. + +--- + +## FM-2 — Creative compliance failure post-launch + +**Status:** Not yet in any module. Prerequisite reading is present in S2 and S4, but no lab exercise walks the post-launch discovery path. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S2** (Creative) | Diagnose / explain + demonstrate | Scenario: a creative passes `preview_creative` compliance at build time, but `validate_content_delivery` returns a violation after flight start (e.g., served variant omits a regulatory disclosure present in the preview render). Learner uses `get_media_buy_artifacts` to retrieve the audit artifact, explains the discrepancy, and describes the remediation options (pause and swap vs. cancel). | +| S4 (secondary) | Diagnose / explain | Learner explains how `get_media_buy_artifacts` and `validate_content_delivery` connect to the governance audit trail. | +| C2 | Surface / recognize | Learner knows that creative compliance can fail post-launch and that the discovery path is distinct from pre-launch checks. | + +**Gap to close before authoring:** Add a numbered lab exercise to S2 that explicitly stages a post-launch compliance failure using `validate_content_delivery`. The prerequisite reading card is present; the exercise is not. Without a staged scenario, assessment relies entirely on conversation and cannot satisfy IACET Element 7 (demonstrable competency evidence). The authoring issue must specify three things to prevent a reading card from being filed as a lab exercise: (a) the sandbox agent must be configured to return a `validate_content_delivery` violation on a specific creative ID after a simulated flight-start event; (b) the learner must invoke `get_media_buy_artifacts` within the same session and correlate the audit artifact to the violation; (c) this constitutes a required demonstration with a stable criterion ID (e.g., `s2_postlaunch_sc0`) — the recertification machinery only fires if the ID exists. + +--- + +## FM-3 — Payment / settlement reconciliation differences + +**Status:** Not yet in any module. Schema being finalized in `#2391` (billing reconciliation, AdCP 3.1). Scoped now against current delivery and accountability-terms surfaces; depth will expand when `#2391` lands. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S1** (Media buy) | Diagnose / explain | Scenario: `get_media_buy_delivery` shows reported impressions 12% below the guaranteed commitment at flight midpoint, and a `billing_measurement` vendor entry that differs from the `measurement_terms` accepted at buy creation. Learner (a) identifies the discrepancy type — delivery shortfall vs. measurement vendor mismatch; (b) names the correct remediation for each — `update_media_buy` to adjust pacing vs. escalating the measurement vendor discrepancy per `measurement_terms` negotiation; (c) identifies which protocol artifact is the settlement record. | + +**Flag for authoring:** Assign a stable criterion ID (e.g., `s1_recon_sc0`) at authoring time. When `#2391` ships a reconciliation schema, S1 credentials issued under the current criteria must be flagged for targeted recertification — the recertification machinery in the instructional design framework only fires if the criterion ID exists. Do not leave this implicit. + +**Depth TBD pending `#2391`:** Once the billing reconciliation schema lands, add a second criterion (`s1_recon_sc1`) covering the new settlement fields. Credentials issued before that addition are candidates for recertification per the protocol-triggered recertification policy. + +--- + +## FM-4 — Governance token mismatch / authorization revoked mid-lifecycle + +**Status:** Partially covered. S4 covers `GOVERNANCE_DENIED` recovery, the 15-step JWS verification, and the `governance_context` correlation model. Mid-lifecycle revocation is distinct from denial at check time and is not yet named as a scenario. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S4** (Governance) | Resolve / demonstrate | Scenario: a `governance_context` token was issued at campaign launch; mid-flight, the governance agent revokes authorization (brand exits a market, rights grant expires). Seller's execution-phase `check_governance` returns a revocation status. Spot-the-error: a seller implementation that silently continues serving after receiving revocation. Learner identifies the failure, explains correct behavior (halt execution, webhook orchestrator, re-run `sync_plans` with updated parameters), and explains which step in the 15-step JWS verification catches key compromise vs. a valid revocation. | +| C2 | Surface / recognize | Learner knows that a governance token can be revoked mid-lifecycle and that the seller's obligation is to halt, not continue. | + +**Gap to close before authoring:** S4's "What you'll demonstrate" section covers the 15-step JWS verification and `governance_context` correlation model but does not name mid-lifecycle revocation as a discrete scenario. Add it as a demonstration item and extend lab exercise 7 ("GOVERNANCE_DENIED recovery") with a revocation-during-execution variant. + +--- + +## FM-5 — Lifecycle state stuck (media buy, creative, account, SI session, catalog) + +**Status:** Not yet in any module as an explicit failure-mode scenario. S1 lab exercise 6 covers forced rejection from `pending_start` but not timeout without response. S5 covers normal SI session management but not stuck or expired sessions. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S1** (Media buy) | Resolve / demonstrate | Scenario: a media buy has been in `pending_start` for 36 hours with no seller-initiated transition and no webhook. Learner (a) uses `get_media_buys` to read `valid_actions` and confirm state; (b) identifies that `cancel` is available from `pending_start`; (c) explains why `pause` is not valid from `pending_start`; (d) describes the webhook the seller MUST send at flight start and what happens when it is absent. | +| **S2** (Creative — sync stuck) | Diagnose / explain | Scenario: `sync_creatives` call returns `accepted` but creative approval status never updates and the buy stays in `pending_creatives`. Learner reads `valid_actions` on the buy, identifies the seller-side obligation, and describes the escalation path. | +| **S5** (SI — session stuck) | Diagnose / explain | Scenario: an SI Chat Protocol session has no `session_end` event after the expected TTL. Learner explains session expiry semantics, what the host must do, and what state risk a non-terminated session creates. | +| D1 / D3 (Platform track) | Surface / recognize | Learner knows that async protocol operations can stall and explains the polling-vs-webhook reconciliation pattern. | + +**Gap to close before authoring:** +- S5's current "What you'll demonstrate" covers normal session management only. Add session expiry and stuck-session recovery as explicit demonstration items. +- S1 lab exercise 6 should be extended (or a variant added) for the timeout-without-response case, distinct from the forced rejection already staged. + +--- + +## FM-6 — Webhook delivery failure / retries + +**Status:** Not yet in any module as a failure scenario. D3 prerequisite reading references error handling but no lab exercise or assessment dimension covers webhook failure. + +| Module | Depth | Assessment approach | +|---|---|---| +| **D3** (Platform) | Diagnose / explain + configure | Scenario: a seller's `active` → `paused` transition is lost — the webhook endpoint returned 503 on first attempt and the retry backoff exceeded the buyer's expectation window. Learner (a) describes correct retry semantics (exponential backoff, idempotency of delivery events); (b) identifies what the buyer agent should do when the expected webhook doesn't arrive (poll `get_media_buys`); (c) explains the tradeoff between webhook-driven and poll-driven state reconciliation. | +| S1 (secondary) | Diagnose / explain (consumer perspective) | Learner explains how to detect a missing webhook, when to poll instead, and how this affects campaign state management. | +| B3 (Publisher track) | Surface / recognize | Learner knows that webhooks can fail and that sellers must implement retries. | + +**Gap to close before authoring:** Add a scenario within D3's error-handling discussion (not necessarily a full new exercise) that walks a webhook delivery failure end-to-end. D3 currently lists the error-handling docs as prerequisite reading but has no lab exercise or assessment item that tests operational recovery. + +--- + +## FM-7 — Signed-request validation failure + +**Status:** Not yet in any module as a failure scenario. S1 covers the buyer-identity resolution chain (signature → JWKS → agent entry → brand.json) conceptually, but no module tests a fail-closed implementation. + +| Module | Depth | Assessment approach | +|---|---|---| +| **D2** (Platform) | Resolve / demonstrate (implementation) | Spot-the-error: a seller implementation accepts a request where `iss` matches a known brand but the JWKS fetch fails with a network error, and the implementation falls back to trusting the `iss` claim. Learner (a) identifies the vulnerability — accepting identity claims without key verification; (b) explains correct behavior — fail closed, reject the request, do not fall back; (c) describes the SSRF risk in the JWKS fetch and the mitigation. | +| S1 (secondary) | Diagnose / explain (reasoning) | Learner explains what each link in the identity chain defends against and why a JWKS fetch failure must not trigger a fall-through to bare `iss` trust. | +| B2 (Publisher track) | Surface / recognize | Learner knows that incoming requests must be signature-verified and that validation failures must reject, not accept. | + +**Note on criterion IDs:** Assign separate criterion IDs for D2 (`d2_sig_sc0`) and S1 (`s1_sig_sc0`) at authoring time so that a future change to RFC 9421 request signing triggers recertification in both modules independently. + +**Related (FM-C below):** `adagents.json` / `brand.json` authorization failure at agent discovery is a closely related onboarding failure mode scoped separately. + +--- + +## FM-8 — TMP provider integration failure + +**Status:** Reclassified from "TMP attestation failure." TMP cryptographic attestation is a SHOULD (not a MUST) in AdCP 3.0 and is marked "future enhancement" in the specification — the current conformance model is publisher-attested via `adagents.json` over HTTPS. Teaching cryptographic attestation failure as a certification topic before the mechanism is stable would credential knowledge of a future feature rather than current protocol behavior. The operationally real failure modes today are: (1) `adagents.json` binding failure — provider not listed, `seller_agent` URL mismatch, or bypass-mode misconfiguration; (2) TMP Router provider configuration failure; (3) Identity Match returning no result due to integration misconfiguration, causing the frequency-cap logic to fall back to no-cap behavior. Home is S1, not S3 — TMP is a media buy execution mechanism, not a signals/audiences topic. + +**Deferred:** Cryptographic attestation failure scenarios will be added when TMP moves out of experimental status. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S1** (Media buy) | Diagnose / explain | Scenario: a TMP Identity Match request returns no result because the `adagents.json` entry for the match provider does not list the seller's `seller_agent` URL, or the bypass mode is misconfigured. The orchestrator's frequency-cap logic falls back to no-cap behavior, resulting in over-delivery. Learner (a) explains what `adagents.json` binding proves — that responses come from a registered TMP provider without exposing user data; (b) describes correct fallback behavior — conservative: treat user as unknown, apply default frequency cap; (c) explains why Context Match and Identity Match are structurally separated and how this failure affects frequency capping but not creative selection. | +| D3 (secondary) | Diagnose / explain (router config) | Learner identifies a provider-configuration gap in the TMP Router setup: `adagents.json` entry missing or `seller_agent` URL mismatch. Explains the diagnostic steps and the configuration change to recover. | +| S3 (tertiary) | Surface / recognize | Learner knows that TMP integration failure degrades identity matching — not context matching — and that the fallback is conservative frequency behavior. | + +**Gap to close before authoring:** Add a failure variant to S1 lab exercise 9 — same cross-publisher suppression scenario, but Identity Match returns no result due to `adagents.json` misconfiguration. This is an extension of an existing exercise, not a new one. + +--- + +## FM-9 — Cross-protocol policy conflicts + + +**Status:** Not yet in any module. S4 covers composing governance domains (campaign, property, collection, content standards, creative) but does not include a scenario where rules from multiple domains conflict and must be adjudicated. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S4** (Governance) | Evaluate / create | Open-ended exam question (no lab): A campaign plan specifies `policy_categories: ['fair_housing']` with `restricted_attributes: ['zip_code']`. The creative governance feature evaluation (`get_creative_features`) passes a creative referencing a zip-code-adjacent geo signal. The signals activation (`activate_signal`) for a geo/mobility provider includes a trade-area segment derived from zip codes but not labeled with `restricted_attributes`. Which governance domain has precedence, what is the buyer agent's obligation, and what would a correct implementation do? Learner must reason across campaign governance (plan-level constraint), creative governance (feature evaluation), and signals governance (restricted attributes on signal metadata). | +| S1, S2, S5 | Cross-reference | Each module's "What you'll demonstrate" section should cross-reference S4 as the authoritative home for cross-domain policy adjudication. No independent assessment in S1/S2/S5. | + +**Rationale for S4 (not a new cross-domain section):** S4 already covers governance domains composing, including their interaction across campaign, property, collection, content standards, and creative. The cross-protocol scenario is an extension of existing S4 scope, assessable with S4 + S3 prerequisite knowledge. A new module section would require authoring scope that this issue explicitly defers. + +**Flag for `#2391`:** If billing reconciliation introduces a governance dimension to payment authorization (e.g., a plan that constrains spend must validate against billing reconciliation rules), add a second criterion ID in S4 for that interaction when `#2391` closes. + +--- + +## FM-A — Account payment required blocking active buys + +**Status:** Not in the original candidate list. Identified during curriculum review as a real, high-frequency operational failure. The account state machine allows an account to transition to `payment_required`, which blocks new spend but does not terminate in-flight campaigns. Buyer agents that treat all authorization failures as transient will over-retry; agents that treat them as fatal will stop managing campaigns that can still be modified. Neither behavior is correct. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S1** (Media buy) | Diagnose / explain | Scenario: a `create_media_buy` returns `ACCOUNT_PAYMENT_REQUIRED`. A second call on the same account to `update_media_buy` (modifying an existing buy) succeeds. Learner (a) explains the distinction between new-spend authorization failure and modification-of-existing-commitment; (b) describes the buyer agent's correct behavior — stop new buys, do not abandon existing ones, surface the payment status to the orchestrator. | + +--- + +## FM-B — report_usage pricing mismatch + +**Status:** Not in the original candidate list. Identified as a billing dispute trigger: the buyer reports a `pricing_option_id` in `report_usage` that does not match the rate negotiated at buy time, and the seller agent rejects it. This is operationally painful for CPA and performance-priced campaigns where the pricing option ID changes between the buy and the reporting call. + +| Module | Depth | Assessment approach | +|---|---|---| +| **S1** (Media buy) | Diagnose / explain | Scenario: a `report_usage` call returns an error because the `pricing_option_id` in the report does not match the `pricing_option_id` accepted at `create_media_buy`. Learner explains where the authoritative pricing option ID is recorded (in the accepted buy response, not the original product listing), why reporting a different ID is a protocol error, and the correct recovery (re-read the buy, extract the accepted pricing option, re-submit the report). | + +**Scope note:** If `#2391` introduces a formal billing reconciliation mechanism, this failure mode may merge with FM-3 into a unified settlement module. Flag for review when `#2391` closes. + +--- + +## FM-C — adagents.json / brand.json authorization failure at discovery + +**Status:** Not in the original candidate list. Identified as the most common onboarding failure for new integrations: a buyer agent discovers a sales agent via `get_adcp_capabilities`, attempts OAuth authentication, and the seller rejects the token because the buyer's `adagents.json` does not declare the correct `authorized_agents[]` relationship — or the `brand.json` entry does not match the agent identity presented in the signed request. + +| Module | Depth | Assessment approach | +|---|---|---| +| **D2** (Platform) | Resolve / demonstrate | Lab scenario: a buyer agent cannot authenticate to a newly integrated seller. Learner reads the seller's error, checks the buyer's `adagents.json` for the `authorized_agents[]` entry, verifies the `brand.json` agent identity, and traces the resolution chain. Correctly identifies which of the three common root causes applies: missing entry, URL mismatch, or expired authorization. | +| S1 (secondary) | Diagnose / explain | Learner explains what `adagents.json` and `brand.json` encode in the discovery and authorization flow, why a mismatch produces a rejection, and how to diagnose it from the buyer side. | +| S4 (secondary) | Diagnose / explain | Learner explains how a `brand.json` authorization failure interacts with the governance token chain — specifically, why a buyer without a valid `authorized_agents[]` entry cannot obtain a governance context token and what the escalation path is. | + +--- + +## Open items tracker + +| Item | Status | Unblocks | +|---|---|---| +| Billing reconciliation schema (`#2391`) | Active / in-flight (3.1 scoping) | FM-3 depth expansion, FM-9 `#2391` flag | +| Idempotency coverage confirmed in training agent (`#2346`, `#2367`) | Shipped | FM-1 authoring (verify lab exercise extension only) | +| Lifecycle state stuck issues (`#1612`–`#1616`) | Open | FM-5 S1/S5 scenario detail | + +--- + +## Module impact summary + +The table below shows which specialist and practitioner modules need authoring follow-ups. Each ✦ is a new lab exercise or demonstration item; each ✧ is a new exam scenario or cross-reference. + +| Module | New lab exercises | New exam scenarios | Cross-refs to add | +|---|---|---|---| +| **S1** | FM-1 (idempotency states), FM-5 (stuck `pending_start`), FM-8 (TMP provider variant) | FM-3 (delivery reconciliation), FM-6 (webhook consumer), FM-A (payment_required), FM-B (pricing mismatch) | FM-9 → S4, FM-C (adagents.json) | +| **S2** | FM-2 (post-launch compliance — required for IACET), FM-5 (creative sync stuck) | — | FM-2 → S1 (state machine cross-ref), FM-9 → S4 | +| **S3** | — | — | FM-8 (TMP provider surface) | +| **S4** | FM-4 (revocation-during-execution variant) | FM-9 (cross-protocol adjudication) | FM-C (adagents.json / governance chain) | +| **S5** | FM-5 (SI session stuck/expired) | — | FM-9 → S4 | +| **D2** | FM-C (adagents.json/brand.json lab) | FM-7 (signed-request fail-closed) | — | +| **D3** | FM-6 (webhook delivery failure), FM-8 (TMP Router config) | — | — | +| **B2** | — | — | FM-7 (surface/recognize) | +| **B3** | — | — | FM-6 (surface/recognize) | +| **C2** | — | — | FM-2, FM-4 (surface/recognize) | +| **C4** | FM-1 (idempotency resolve/demonstrate) | — | — | +| **A2** | — | — | FM-1 (surface/recognize) | diff --git a/dist/docs/3.0.13/learning/foundations/a1-agentic-advertising.mdx b/dist/docs/3.0.13/learning/foundations/a1-agentic-advertising.mdx new file mode 100644 index 0000000000..47e1fce6fc --- /dev/null +++ b/dist/docs/3.0.13/learning/foundations/a1-agentic-advertising.mdx @@ -0,0 +1,98 @@ +--- +title: "A1: Why AdCP" +sidebarTitle: "A1: Why AdCP" +description: "Module A1: What is agentic advertising and why does AdCP exist? Free 15-minute interactive module covering platform fragmentation and the case for a shared protocol." +"og:title": "AdCP — A1: Why AdCP" +--- + +# A1: Why AdCP + + +**Free module** — No account required. ~15 minutes with Addie. + + +## Learning objectives + +By the end of this module, you should be able to: + +- Explain why agentic advertising is a paradigm shift from programmatic +- Describe what AI agents are and how they differ from traditional APIs +- Articulate the fragmentation problem AdCP solves +- Recognize AdCP's breadth: 20 channels from display to local radio to cinema +- Explain how campaign governance provides always-on compliance for autonomous agent transactions +- Explain why security changes when agents transact autonomously — the manual sign-off that used to backstop every buy before money moved is gone + +## Reading list + +Review these pages before starting the module with Addie. They provide the conceptual foundation for everything in the certification program. + + + + What AdCP is, how it works, and the protocol domains. Start here if you're completely new. + + + The strategic vision: allocation vs day trading, the fragmentation problem, and why agents need a shared protocol. + + + How AdCP compares to OpenRTB, platform APIs, and direct IO. Understand where agentic advertising fits in the landscape. + + + See how to implement AdCP. Optional but helpful for building intuition. + + + How campaign governance ties campaigns to media plans and validates every transaction autonomously. + + + The five principles that define how humans stay in control when agents execute autonomously. + + + What changes for security when an agent spends money without a human in the loop — and the trust primitives that make it safe. + + + +## Key terms + +| Term | Definition | +|------|-----------| +| **Agentic advertising** | Advertising executed by AI agents that can reason, negotiate, and adapt — as opposed to rigid API integrations | +| **AI agent** | Software that perceives its environment, makes decisions, and takes actions autonomously | +| **AdCP** | Ad Context Protocol — an open standard that gives AI agents a shared language for advertising | +| **MCP** | Model Context Protocol — a standard for connecting AI models to external tools and data sources. AdCP uses MCP as its transport layer | +| **Task** | A discrete advertising operation (e.g., `get_products`, `create_media_buy`) defined by AdCP | +| **Campaign governance** | Always-on compliance that ties campaigns to media plans and validates every transaction through three independent parties | +| **Embedded Human Judgment** | The principle that humans define intent, set boundaries, and retain override authority — agents execute within those boundaries | + +For the full glossary, see the [AdCP glossary](/dist/docs/3.0.13/reference/glossary). + +## What you'll do with Addie + +This module is grounded with a live agent query — you'll see a real `get_products` response from `@cptestagent`, not a slide deck. Addie will guide you through: + +- What makes an AI agent different from a traditional API? +- Why does a shared protocol matter for AI-powered advertising? +- What problems arise if every ad tech company builds their own agent protocol? +- How does the "allocation vs day trading" framing change how you think about media buying? +- How do you trust AI agents to spend your money? (Always-on compliance: every transaction validated against the plan, grounded in [Embedded Human Judgment](/dist/docs/3.0.13/governance/embedded-human-judgment) principles) +- What security changes when there is no human approval gate before spend — and how AdCP answers it with three primitives: verifiable requests, retry-safe transactions, and signed approvals +- 20 channels: display, social, search, CTV, linear TV, radio, podcast, DOOH, OOH, print, cinema, gaming, retail media, influencer, affiliate, product placement, AI media + +## Assessment + +Addie evaluates your understanding across four dimensions: + +| Dimension | Weight | What Addie looks for | +|-----------|--------|---------------------| +| Conceptual understanding | 25% | Can you articulate the paradigm shift from programmatic to agentic? | +| Practical knowledge | 35% | Can you query an agent and interpret the response? | +| Channel breadth | 20% | Do you understand AdCP covers 20 channels, not just digital? | +| Protocol fluency | 20% | Do you use AdCP terminology correctly? | + +Passing threshold: 70%. This is a conversation, not a test — Addie will help you get there. + +## Start this module + + + Open Addie and say "I'd like to start certification module A1." Addie will take it from there. + + +**Next:** [A2: Your first media buy](/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture) diff --git a/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture.mdx b/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture.mdx new file mode 100644 index 0000000000..9aca579caf --- /dev/null +++ b/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture.mdx @@ -0,0 +1,95 @@ +--- +title: "A2: Your first media buy" +sidebarTitle: "A2: Your first media buy" +description: "Module A2: Execute your first AdCP media buy. Free 20-minute hands-on module walking through discovery, purchase, creative sync, and delivery with a live sandbox agent." +"og:title": "AdCP — A2: Your first media buy" +--- + +# A2: Your first media buy + + +**Free module** — No account required. ~20 minutes with Addie. Prerequisite: [A1](/dist/docs/3.0.13/learning/foundations/a1-agentic-advertising). + + +## Learning objectives + +- Execute the full media buy lifecycle: discovery, purchase, creative sync, delivery +- Identify the agent roles involved: buyer agent, sales agent, creative agent, signals agent +- Read and understand real protocol messages at each stage +- Observe a live agent-to-agent transaction +- Recognize the two things that make agent-to-agent requests safe: the request is signed (so the seller can verify who sent it) and it carries an `idempotency_key` (so a retry doesn't double-book) + +## Reading list + + + + The complete architecture: domain map, identity layer, transaction domains, governance, and ecosystem layers. + + + How MCP works in practice: tool calls, response format, context management, async operations. + + + How agents advertise their capabilities so other agents can discover what they offer. + + + The agent discovery mechanism — like robots.txt for advertising agents. + + + The Agent-to-Agent protocol — how specialized agents collaborate on complex campaigns. + + + How tasks move through states: from request to completion, including async operations. + + + +## Key terms + +| Term | Definition | +|------|-----------| +| **Sales agent** | Represents a publisher and exposes inventory via `get_products`. May also implement the Creative Protocol to handle creatives from the same endpoint. | +| **Buyer agent** | Represents a brand or agency and purchases media via `create_media_buy` | +| **Brand agent** | Manages brand identity and guidelines via `brand.json` | +| **Creative agent** | Any agent implementing the Creative Protocol — produces and adapts advertising assets via `build_creative`. This can be a standalone service or a sales agent that declares `"creative"` in `supported_protocols`. | +| **Signals agent** | Provides measurement and audience data via `get_signals` | +| **adagents.json** | Publisher-hosted file declaring agent capabilities (like robots.txt for agents) | +| **Tool discovery** | The process of an agent reading another agent's capabilities to know what tasks it supports | +| **Request signing** | A cryptographic signature on the request that lets the seller verify who sent it and that it wasn't tampered with in transit | +| **Idempotency key** | A unique tag on each mutating request that lets a buyer safely retry without creating a duplicate | +| **Domain** | A broad protocol area an agent supports — `media_buy`, `creative`, `signals`, `governance`, `brand`, `sponsored_intelligence`. Declared via `supported_protocols`. | +| **Specialism** | A narrow capability claim within a domain — e.g. `sales-guaranteed`, `creative-generative`, `signal-marketplace`. Declared via `specialisms`. See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog). | +| **Storyboard** | A scripted compliance scenario shipped by the protocol at `/compliance/{version}/` — agents don't write them, they're run against agents. An agent demonstrates its domain and specialism claims by passing the matching storyboards. | + +## Protocol versioning + +Every request carries `adcp_major_version`; sellers advertise supported versions on `get_adcp_capabilities`. A3 and B1 go deeper on version negotiation and the object-presence pattern sellers use to declare capabilities. + +## What you'll do with Addie + +Tell Addie what you want: audience, goals, budget. Then walk through each step as it happens: + +1. **Discovery** — `get_products` against `@cptestagent`, examine real response structure +2. **Purchase** — `create_media_buy` with targeting and budget. Addie points out two things on this request: it carries a signature (so the seller can verify the buyer) and an `idempotency_key` (so a retry after a network error never creates two buys). Note the `confirmed_at` timestamp in the response — this is the seller's order confirmation. +3. **Creative** — `sync_creatives` to deliver assets to the publisher +4. **Status check** — `get_media_buys` to see lifecycle state, creative approvals, and `valid_actions` +5. **Delivery** — `get_media_buy_delivery` to see results + +You'll see the actual protocol messages at each stage. By the end, you've bought media through an agent. + +## Assessment + +| Dimension | Weight | What Addie looks for | +|-----------|--------|---------------------| +| Conceptual understanding | 25% | Can you describe the transaction flow and which agent handles what? | +| Practical knowledge | 35% | Can you direct a media buy and interpret the delivery report? | +| Problem solving | 15% | Can you reason about what happens when things go wrong? | +| Protocol fluency | 25% | Do you use correct task names and agent roles? | + +Passing threshold: 70%. + +## Start this module + + + Open Addie and say "I'd like to start certification module A2." + + +**Next:** [A3: The AdCP landscape](/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance) diff --git a/dist/docs/3.0.13/learning/foundations/a2b-testing-your-first-agent.mdx b/dist/docs/3.0.13/learning/foundations/a2b-testing-your-first-agent.mdx new file mode 100644 index 0000000000..cc6fe31228 --- /dev/null +++ b/dist/docs/3.0.13/learning/foundations/a2b-testing-your-first-agent.mdx @@ -0,0 +1,279 @@ +--- +title: "A2B: Testing your first agent call" +sidebarTitle: "A2B: Testing your first agent call" +description: "Module A2B: Hands-on lab — initialize an MCP session, call get_products, place a media buy, attach creatives, and handle real response shapes with copy-paste curl examples." +"og:title": "AdCP — A2B: Testing your first agent call" +--- + +# A2B: Testing your first agent call + + +**Free module** — No account required. ~20 minutes with Addie. Prerequisite: [A2](/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture). + + +## Learning objectives + +- Initialize a stateful MCP session against the AdCP test agent +- Call `get_products` with a natural-language brief and read the product response +- Place a media buy with `create_media_buy` and handle all three response shapes +- Attach creatives with `sync_creatives` and check buy status via `get_media_buys` +- Diagnose and resolve auth failures, schema mismatches, and async polling delays + +## Reading list + + + + End-to-end buyer workflow from setup to delivery. + + + Media buy status states — pending_creatives, pending_start, active, paused, completed — and what each means for the buyer. + + + Full field reference, required fields, and all three response shapes. + + + How to attach assets to a buy, dry-run validation, and assignment patterns. + + + Error codes, retry behavior, and how to read the `errors[]` array. + + + Session initialization, the `mcp-session-id` header, and tool call format. + + + +## Test agent + +All curl examples below target the AdCP training agent: + +``` +https://test-agent.adcontextprotocol.org/mcp +``` + +You'll need an API key from your [AgenticAdvertising.org dashboard](https://agenticadvertising.org/dashboard). Replace `` in every example. + +## What you'll do with Addie + +Walk through five calls in sequence. Addie demonstrates each call, shows the raw response, then guides you through reproducing it yourself. + +1. **Initialize** — open a stateful MCP session; save the `mcp-session-id` header +2. **Discover** — `get_products` with a brief; read proposals +3. **Buy** — `create_media_buy`; handle all three response shapes +4. **Attach creatives** — `sync_creatives`; validate with dry-run first +5. **Poll status** — `get_media_buys` until `valid_actions` shows the buy is serving + +## Step-by-step curl reference + +Use these as a quick reference while working through the module with Addie, or to reproduce any step independently. + +### Step 1 — Initialize a session + +Every sequence starts with an `initialize` call. The response sets the protocol version and returns an `mcp-session-id` header — save it. + +```bash +curl -X POST https://test-agent.adcontextprotocol.org/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "my-buyer-agent", "version": "1.0" } + }, + "id": 1 + }' +``` + +The response includes an `mcp-session-id` in the response headers. Every subsequent call must include it: + +``` +mcp-session-id: +``` + +### Step 2 — Discover products + +Call `get_products` with `buying_mode: "brief"` and a plain-English description of your campaign goals. The agent returns curated `products[]` and ready-to-execute `proposals[]`. + +```bash +curl -X POST https://test-agent.adcontextprotocol.org/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "mcp-session-id: " \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "get_products", + "arguments": { + "adcp_major_version": 3, + "buying_mode": "brief", + "brief": "CTV campaign, adults 25-54 in the US, $50K budget, brand safety required" + } + }, + "id": 2 + }' +``` + +The result is in `content[0].text` as JSON. Look for `proposals[0].proposal_id` — you'll pass it to `create_media_buy`. + +### Step 3 — Place a media buy + +Pass the `proposal_id` from Step 2 and a `total_budget`. The `idempotency_key` lets you safely retry if the network drops — use a fresh UUID v4 per request. + +```bash +curl -X POST https://test-agent.adcontextprotocol.org/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "mcp-session-id: " \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "create_media_buy", + "arguments": { + "adcp_major_version": 3, + "idempotency_key": "mb-lab-20260428-001", + "account": { + "brand": { "domain": "nova-motors.com" }, + "operator": "pinnacle-media.com" + }, + "proposal_id": "", + "total_budget": { "amount": 50000, "currency": "USD" } + } + }, + "id": 3 + }' +``` + +**Three response shapes — you'll see one of these:** + +| Shape | What it means | Next step | +|---|---|---| +| `media_buy_id` + `status: "pending_creatives"` | Buy confirmed; attach creatives | Go to Step 4 | +| `media_buy_id` + `status: "pending_start"` or `"active"` | Buy confirmed and ready | Creatives already attached or not required | +| `status: "submitted"` + `task_id` | Buy queued for async processing | Poll `tasks/get` with `task_id` (see [Async polling](#async-polling) below) | +| `errors[]` present, no `media_buy_id` | Rejected — read `errors[0].code` | Fix the request and retry with a new `idempotency_key` | + +### Step 4 — Attach creatives + +A buy in `pending_creatives` state can't serve until you call `sync_creatives`. Use `dry_run: true` first to validate your creative shapes without writing anything. + +```bash +curl -X POST https://test-agent.adcontextprotocol.org/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "mcp-session-id: " \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "sync_creatives", + "arguments": { + "adcp_major_version": 3, + "idempotency_key": "sc-lab-20260428-001", + "account": { + "brand": { "domain": "nova-motors.com" }, + "operator": "pinnacle-media.com" + }, + "creatives": [ + { + "creative_id": "nova-ctv-30s-v1", + "format_id": { + "agent_url": "https://test-agent.adcontextprotocol.org", + "id": "ctv_1920x1080_30s" + }, + "assets": [ + { + "asset_id": "video_url", + "url": "https://cdn.example.com/nova-ctv-30s.mp4" + } + ] + } + ], + "dry_run": true + } + }, + "id": 4 + }' +``` + +Remove `"dry_run": true` to apply. The response includes `creatives[].status` — `approved`, `pending_review`, or `rejected`. + +### Step 5 — Check status + +Poll `get_media_buys` with the `media_buy_id` from Step 3 to see lifecycle state and `valid_actions`. + +```bash +curl -X POST https://test-agent.adcontextprotocol.org/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "mcp-session-id: " \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "get_media_buys", + "arguments": { + "adcp_major_version": 3, + "media_buy_ids": [""] + } + }, + "id": 5 + }' +``` + +The `media_buys[0].status` field is one of `pending_creatives`, `pending_start`, `active`, `paused`, `completed`, `rejected`, or `canceled`. The `valid_actions` array tells you what the buyer can do next. + +## Async polling + +When `create_media_buy` returns `status: "submitted"` and a `task_id`, the buy is queued. Poll until the task completes: + +```bash +curl -X POST https://test-agent.adcontextprotocol.org/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "mcp-session-id: " \ + -d '{ + "jsonrpc": "2.0", + "method": "tasks/get", + "params": { "task_id": "", "include_result": true }, + "id": 6 + }' +``` + + +`tasks/get` is an MCP protocol-level method, not an AdCP tool — it uses `"method": "tasks/get"` directly rather than the `"method": "tools/call"` + `"name": "..."` pattern used for AdCP tasks. It is auto-registered by the MCP SDK alongside `tasks/result`, `tasks/list`, and `tasks/cancel`. + + +Poll every 2–5 seconds. When `task.status` is `completed`, the `result` field contains the full `create_media_buy` response with `media_buy_id`. See the [Task lifecycle](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) doc for all MCP task status values. + +## Common errors + +| Symptom | Likely cause | Fix | +|---|---|---| +| HTTP 401 / `error: "invalid_token"` | Expired or wrong API key | Reissue the token from your dashboard; confirm the `Bearer` prefix | +| HTTP 401 / `error: "invalid_request"` | `Authorization` header missing | Add `-H "Authorization: Bearer "` to every call | +| `errors[]` in body, no `media_buy_id` | Schema validation failure | Read `errors[0].field` and `errors[0].code`; fix the field and retry with a **new** `idempotency_key` | +| `status: "submitted"` stays indefinitely | Async task stalled | Check `task.status` via `tasks/get`; if `failed`, read `task.error.message` for the rejection reason | +| `mcp-session-id: invalid` error | Session expired or header missing | Re-run Step 1 to get a fresh session ID | + +## Assessment + +| Dimension | Weight | What Addie looks for | +|-----------|--------|---------------------| +| Conceptual understanding | 10% | Can you describe the MCP session lifecycle and why `mcp-session-id` is required? | +| Practical knowledge | 40% | Can you trace through all five calls in order with correct task names and request shapes? | +| Problem solving | 30% | Can you reason through what happens when each step fails or returns an unexpected response? | +| Error recovery | 20% | Can you identify the correct fix for auth failures, schema errors, and async polling delays? | + +Passing threshold: 70%. + +## Start this module + + + Open Addie and say "I'd like to start certification module A2B." + + +**Next:** [A3: The AdCP landscape](/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance) diff --git a/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance.mdx b/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance.mdx new file mode 100644 index 0000000000..c438666e45 --- /dev/null +++ b/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance.mdx @@ -0,0 +1,110 @@ +--- +title: "A3: The AdCP landscape" +sidebarTitle: "A3: The AdCP landscape" +description: "Module A3: Survey of every AdCP domain — media buy, creative, catalogs, accounts, signals, governance, sponsored intelligence, and the Trusted Match Protocol. Free 15-minute interactive overview." +"og:title": "AdCP — A3: The AdCP landscape" +--- + +# A3: The AdCP landscape + + +**Free module** — No account required. ~15 minutes with Addie. Prerequisite: [A2](/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture). + + +This is the survey course. Touch everything, go deep on nothing. You'll get a map of the entire AdCP ecosystem — every protocol domain, every discovery mechanism, every governance layer. Designed to pique your curiosity and show you where to go deeper. + +Completing A1 + A2 + A3 earns the **AdCP basics** credential. + +## Learning objectives + +- Describe what `brand.json` is and why every brand should have one +- Explain `adagents.json` and how agents discover each other +- Name all eight AdCP protocol domains and what each covers +- Identify which areas you want to explore deeper in a role track +- Recognize the three trust primitives that make the ecosystem safe: authenticated identity, signed governance tokens, and agent/account isolation + +## Reading list + + + + Brand identity claims, brand.json discovery, and brand hierarchy. Four variants: house portfolio, brand agent, redirects, minimal. + + + The governance protocol: property governance, brand governance, content standards, creative governance, and campaign governance. + + + Always-on compliance: ties campaigns to media plans, validates every transaction through three independent parties. + + + Community-maintained library of advertising regulations and standards that brands reference by ID. + + + The five principles behind AdCP governance — how humans stay in control when agents act autonomously. + + + The creative protocol: assets, formats, manifests, creative agents, and 20 channels of adaptation. + + + The signals protocol: audience segments, contextual signals, measurement data, and optimization. + + + Conversational brand experiences in AI assistants — a genuinely new advertising model. + + + Real-time execution: context match and identity match activate packages at serve time across all surfaces. + + + How agents advertise what they support so other agents can discover them. + + + The commercial layer: advertisers, operators, authentication, billing, and account lifecycle. + + + How AdCP defends against unauthorized spend, cross-tenant leakage, replay attacks, and SSRF across the ecosystem. + + + +## What you'll cover with Addie + +Addie walks through each domain with a quick live example — just enough to understand what each area does: + +**Discovery and community:** +- `brand.json`: your brand's machine-readable identity at `/.well-known/brand.json` +- `adagents.json`: how publishers declare which agents can access their inventory +- Community registry: how agents and brands find each other +- AgenticAdvertising.org: working groups, industry councils, how the spec evolves + +**Trust primitives (how the ecosystem stays safe):** +- **Authenticated identity** — cryptographic proof of which agent is calling, not a header claim the seller has to take on faith +- **Signed governance context** — approval tokens from the buyer's governance agent that bind a plan to a specific seller and phase, verifiable by auditors years later +- **Agent and account isolation** — every piece of state is scoped to the calling agent and the account it is acting on; cross-tenant reads are rejected with a generic "not found" + +**Protocol domains:** +- **Accounts** — commercial identity, operator-billed vs agent-billed, account lifecycle, `get_adcp_capabilities` +- **Media buy** — proposals, forecasting, refinement, packages, keyword targeting, geo-proximity +- **Creative** — formats vs manifests, 20 channels, AI-powered generation with `build_creative` +- **Signals** — audience data, privacy-compliant signals, conversion tracking, attribution +- **Governance** — content standards, property lists, the Oracle model for AI-driven brand safety, campaign governance (multi-party validation, budget authority, policy registry) +- **Sponsored Intelligence** — conversational brand experiences, cost-per-conversation instead of CPM ([experimental](/dist/docs/3.0.13/reference/experimental-status) in 3.0) +- **Trusted Match Protocol** — impression-time execution: context match (content fit) and identity match (user eligibility), cross-publisher frequency capping, works across web, mobile, CTV, AI assistants, and retail media +- **Brand Protocol** — `brand.json` resolution, brand identity claims, brand hierarchy +- **Registry** — entity resolution, agent discovery, community directory + +## Assessment + +| Dimension | Weight | What Addie looks for | +|-----------|--------|---------------------| +| Breadth | 35% | Can you describe what each protocol domain does? | +| Discovery mechanisms | 25% | Do you understand brand.json, adagents.json, and capability discovery? | +| Key concepts | 25% | Can you explain format vs manifest, billing models, the Oracle model, and context match vs identity match (TMP's two-operation model)? | +| Synthesis | 15% | Can you connect concepts across domains without prompting? | + +Passing threshold: 70%. + +## Start this module + + + Open Addie and say "I'd like to start certification module A3." + + +**Next:** Choose your role track — [Publisher](/dist/docs/3.0.13/learning/tracks/publisher), [Buyer](/dist/docs/3.0.13/learning/tracks/buyer), or [Platform](/dist/docs/3.0.13/learning/tracks/platform) diff --git a/dist/docs/3.0.13/learning/instructional-design.mdx b/dist/docs/3.0.13/learning/instructional-design.mdx new file mode 100644 index 0000000000..8b4764118f --- /dev/null +++ b/dist/docs/3.0.13/learning/instructional-design.mdx @@ -0,0 +1,301 @@ +--- +title: Instructional design framework +sidebarTitle: How we teach +description: "AdCP certification instructional design: teaching methodology, adaptive assessment, AI-guided learning, and IACET-aligned quality processes." +"og:title": "AdCP — Instructional design framework" +--- + +# Instructional design framework + +This document describes how the AdCP certification program is designed, delivered, and maintained. It serves as the authoritative reference for teaching methodology and program quality. + +### Accreditation element reference + +| IACET Element | Section | +|--------------|---------| +| 2 — Learning environment | [Simulated workplace environment](#simulated-workplace-environment), [Learner support](#learner-support) | +| 3 — Instructional personnel | [Instructional personnel](#instructional-personnel) | +| 5 — Learning outcomes | [Learning outcomes and curriculum structure](#learning-outcomes-and-curriculum-structure) | +| 6 — Content and instruction | [Teaching philosophy](#teaching-philosophy), [Module design patterns](#module-design-patterns) | +| 7 — Assessment | [Competency-based assessment](#competency-based-assessment) | +| 9 — Evaluation | [Curriculum maintenance](#curriculum-maintenance), [Quality assurance](#quality-assurance) | + +## Teaching philosophy + +The certification program is built on five principles: + +**Socratic method.** Addie teaches through conversation, not lecture. Most responses include a question or task, though Addie also affirms and builds on strong answers — the rhythm alternates between teaching and questioning rather than interrogating. Learners build understanding by reasoning through problems, not by receiving answers. + +**Mastery-based progression.** There is no failing — only "not yet." Learners keep working with Addie until they demonstrate mastery of every learning objective. Assessment is invisible to the learner; they experience it as continued learning until they pass. + +**Personalization.** Addie adapts to each learner's background, role, and communication style. If a learner sells running shoes, examples are about running shoes. If they're technical, Addie is technical. Context carries across the entire session. + +**Active learning.** Responses are kept under 150 words. One idea per turn. Brevity forces participation. Learners construct knowledge through exercises, demos against live sandbox agents, and scenario-based reasoning. + +**Concrete language.** Abstract jargon is always grounded in specific behavior. Not "agents reason about impressions" but "agents evaluate whether a placement fits the campaign goals and decide how much to bid." + +## Learning outcomes and curriculum structure + +### Three-tier credential model + +The program awards three credentials with increasing depth: + +| Tier | Credential | Modules | Requirements | +|------|-----------|---------|--------------| +| 1 | AdCP basics | A1, A2, A3 | Free — open to everyone | +| 2 | AdCP practitioner | Basics + one role track (B, C, or D) | Includes a hands-on build project | +| 3 | AdCP specialist | Practitioner + specialist capstone (S1-S5) | Lab exercises + adaptive exam | + +### Bloom's taxonomy alignment + +Learning objectives scale with tier: + +- **Basics (A track):** Understand and apply — learners explain what agentic advertising is, how AdCP works, and the ecosystem structure +- **Practitioner tracks (B, C, D):** Apply and analyze — learners configure agents, interpret responses, and reason about trade-offs +- **Build projects (B4, C4, D4):** Create and evaluate — learners build working AdCP agents and defend design decisions +- **Specialist capstones (S1-S5):** Analyze, evaluate, and create — learners demonstrate protocol mastery through hands-on labs and adaptive assessment + +### Prerequisite enforcement + +Modules have explicit prerequisites. The system prevents starting advanced modules without completing foundations. Build projects require all track modules. Specialist capstones require the Practitioner credential. + +## Instructional personnel + +### AI teaching assistant + +Addie is powered by Claude (Anthropic). Teaching behavior is governed by operational rules injected at runtime, not freeform AI generation. These rules specify: + +- Socratic methodology and turn structure +- Assessment fairness and scoring calibration +- Learner data handling and privacy +- When and how to use tools (demos, exercises, checkpoints) + +### Curriculum design + +Module lesson plans, assessment criteria, and scoring rubrics are designed by subject matter experts with expertise in advertising technology and the AdCP protocol. Content accuracy is validated against the AdCP specification, which serves as the single source of truth for protocol facts. + +### Human oversight + +Program leadership oversees teaching quality through: + +- **Score monitoring** — admin dashboard tracks scores by module, dimension, config version, and time period. Anomalies (e.g., consistently low scores in a dimension) trigger curriculum review +- **Feedback review** — learner feedback is collected after every module completion. Program leadership reviews feedback quarterly, with negative-sentiment patterns triggering immediate review +- **Teaching behavior audit** — changes to teaching methodology require a `CODE_VERSION` bump, enabling before/after comparison of learner outcomes +- **Curriculum review** — all curriculum changes go through code review before deployment. Protocol accuracy is validated against the AdCP specification +- **Learner escalation** — learners who need human assistance can contact certification@agenticadvertising.org. Assessment disputes are handled through the [complaints process](/dist/docs/3.0.13/learning/policies/complaints) + +## Competency-based assessment + +### Formative assessment + +Assessment happens continuously during instruction: + +- **Socratic questioning** throughout every module — Addie probes understanding, corrects misconceptions, and adjusts depth based on responses +- **Teaching checkpoints** saved at concept group boundaries, recording concepts covered, concepts remaining, learner strengths, learner gaps, and preliminary scores +- **Checkpoint consistency** — final scores cannot jump more than 20 points from preliminary scores recorded during checkpoints + +### Summative assessment + +Each module defines 3-5 assessment dimensions with explicit rubrics: + +- Each dimension has a weight, description, and scoring guide (high/medium/low ranges) +- **50% floor per dimension** — learners must demonstrate baseline competency in every area +- **70% weighted average threshold** for mastery +- Score calibration: 70 = met bar with coaching, 85 = demonstrated independently, 95+ = depth beyond what was taught + +Scores are internal only. Learners never see percentages or dimension breakdowns. Their experience is: keep learning until mastery, then receive their credential. + + +Assessment occurs continuously through the instructional conversation rather than in a separate testing phase. This reduces test anxiety, enables immediate remediation, and aligns with mastery-based learning principles. Formative assessment (Socratic questioning, teaching checkpoints) and summative assessment (dimension scoring at module completion) remain distinct processes even though they occur within the same learner interaction. + +For expert learners who demonstrate competency early, teaching is compressed but assessment requirements remain identical. The conversation transcript serves as auditable evidence: it shows the learner's own words demonstrating understanding of each assessment dimension. Teaching checkpoints record which dimensions were assessed, preliminary scores, and learner background context. + + +### Simulated workplace environment + +Learners work in simulated professional contexts that mirror production environments: + +- **Sandbox agents** — implement the same AdCP protocol endpoints that production agents use. Learners practice with real tool calls, real JSON schemas, and real response formats — the only difference is that sandbox agents serve test data rather than production inventory +- **Build projects** (B4, C4, D4) — learners create working agents using AI coding assistants, then validate responses against AdCP schemas and explain their implementations +- **Specialist labs** (S1-S5) — guided exercises using real AdCP tools against sandbox agents, followed by adaptive questioning + +### Assessment fairness + +Every learner must demonstrate the same core competencies, regardless of background or experience level. + +Each module defines 3–5 **required demonstrations** — specific, observable things a learner must do or explain during the conversation. These are the same for everyone: + +- **A1** (3 demonstrations): query a live agent, interpret the response fields, explain that the protocol works across all channels +- **A2** (3 demonstrations): direct a media buy, identify each transaction step, map protocol tasks to lifecycle stages +- **A3** (4 demonstrations): identify which protocol domain handles a given scenario, explain brand.json's role in agent discovery, explain the format/manifest distinction, describe how Sponsored Intelligence works as a conversation +- **Build projects** (9 demonstrations across specify/validate/extend phases): write a specification using correct terminology, validate against live schemas, extend with a new capability +- **Specialist capstones** (3–5 demonstrations): protocol-specific mastery tasks using live tools + +Addie verifies each demonstration through conversation and records it in a teaching checkpoint using a stable criterion ID (e.g., `a1_ex1_sc0`). The server rejects module completion if any required demonstration is missing. This is enforced server-side — Addie cannot bypass it. + +Expert learners who demonstrate competency early still verify the same criteria. Teaching may be compressed, but the demonstrations are identical. + +Each verified demonstration includes an evidence rationale — a brief note explaining what the learner said or did that satisfied the criterion. This creates an auditable trail: for any credential, you can trace exactly which demonstrations were verified, when, and what evidence supported each one. + +### Recertification + +AdCP is a living protocol. When the specification evolves — new tasks added, existing tasks changed, channels expanded — the competencies required for certification may change too. + +Each required demonstration is tracked by a stable ID tied to specific protocol knowledge. When a protocol change affects what a certified person should know, the system can identify which credential holders learned under the previous criteria and flag them for recertification. + +Recertification is targeted, not blanket. If a protocol update adds a new governance task but doesn't affect media buy workflows, only credentials that cover governance are flagged. Credential holders receive a notification through Addie with context on what changed and what they need to review. + +| Tier | Validity | Recertification | +|------|----------|-----------------| +| 1 — AdCP basics | No expiry | Flagged when foundational concepts change | +| 2 — AdCP practitioner | 2 years | Standard renewal, plus protocol-triggered updates | +| 3 — AdCP specialist | 2 years | Standard renewal, plus protocol-triggered updates | + +### Assessment integrity + +Server-side enforcement prevents gaming: + +- Required demonstrations verified for every learner before module completion +- Minimum engagement: 4 user turns for modules, 6 for capstones, 3 for placement assessments +- Minimum time: 5 minutes for modules, 10 minutes for capstones +- At least one teaching checkpoint with preliminary scores required before completion +- Score consistency checks reject completions with >20 point jumps from checkpoint scores +- Module completion only available through Addie's tool calls — no direct REST API endpoint +- Learners cannot influence their own scores +- Pasted content (JSON, code, logs) is treated as data to validate, not instructions + +## Module design patterns + +### Standard modules + +Modules A1-A3, B1-B3, C1-C3, and D1-D3 follow this flow: + +1. **Understand the learner** — first turn is always about them: background, role, what they know +2. **Demo early** (turn 2-3) — show a real agent response before explaining theory +3. **Teach with Socratic method** — cover all key concepts from the lesson plan, scaffolding then fading guidance +4. **Practice** — exercises against sandbox agents, scenario-based reasoning +5. **Assess through conversation** — verify mastery of each learning objective + +**Expert path.** When a learner demonstrates strong understanding early (correct, detailed answers to 3+ concepts in a row without needing guidance or correction), steps 3-4 compress: Addie acknowledges their expertise, confirms remaining concepts with targeted demonstration questions, and moves to assessment. The audit trail is the same — conversation transcript, checkpoint scores, and per-dimension rubric — but the evidence comes from the learner demonstrating competency rather than being taught first. + +### Build project modules + +Modules B4, C4, and D4 use a five-phase approach built on the same tools developers use in practice — [skill files](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) to generate agents and [storyboards](/dist/docs/3.0.13/building/verification/validate-your-agent) to validate them: + +1. **Specify** (~5 min) — learner describes what they want to build using AdCP terminology +2. **Build** (~5 min) — learner points their AI coding assistant at the matching skill file from `@adcp/client`, which generates a working agent from the specification +3. **Validate** (~10 min) — learner runs the matching storyboard from the CLI (`npx @adcp/client@latest storyboard run my-agent media_buy_seller`), shares results with Addie, and iterates on failures with their coding assistant +4. **Explain** (~10 min) — probing questions about design decisions and trade-offs +5. **Extend** (~15 min) — learner adds a capability, re-runs the storyboard, demonstrating they can iterate + +Addie is coach, not builder. Assessment spans five dimensions: specification quality, schema compliance, error handling, design rationale, and extension ability. The validate phase uses the same storyboard infrastructure that developers use to maintain compliance after certification. + +### Specialist capstone modules + +Modules S1-S5 combine hands-on lab work with adaptive examination: + +1. **Lab phase** — guided exercises using real AdCP tools against sandbox agents +2. **Checkpoint** — required after lab, recording observations before the exam +3. **Exam phase** — 6-10 follow-up questions covering assessment dimensions, with difficulty adapting to responses + +Formats include open-ended questions, multiple-choice, scenario-based problems, and "spot the error" comparisons. + +## Learner support + +**Returning learners.** Teaching checkpoints enable cross-session resume. When a learner returns, Addie starts with a retrieval question on the last concept covered — not a cold restart. + +**Disengaged learners.** If a learner gives repeated short answers or seems checked out, Addie switches approach: runs a demo, connects the concept to the learner's stated goals, or acknowledges the abstraction and makes it concrete. + +**Overqualified learners.** Teaching and assessment serve different purposes. Teaching is for the learner; assessment is for the credential. When a learner demonstrates strong understanding of 3+ concepts in a row without needing guidance, Addie compresses *teaching* but not *assessment*. The expert path replaces instruction with demonstration: instead of "let me teach you X, now let me ask about X," Addie asks "show me you understand X" directly. This produces stronger audit evidence (the learner's own words demonstrating competency) while respecting their time. The same assessment dimensions, scoring rubrics, and minimum engagement requirements apply regardless of path. + +**Placement assessment.** Learners who demonstrate existing knowledge can test out of modules (except build projects and specialist capstones), satisfying prerequisites without repeating content. + +**Pacing.** Addie suggests breaks after 45+ minutes or 2+ consecutive modules. Module transitions carry personalization context forward with a compressed warm-up connecting the completed module to the next one. + +## Credential issuance + +When a learner completes all required modules for a credential tier, the system automatically: + +1. Verifies all prerequisites and module completions +2. Awards the credential and records the date +3. Issues a digital badge through Certifier with a unique verification URL and QR code +4. Notifies the learner and provides sharing options (LinkedIn, public profile) + +**Credential validity:** Basics credentials do not expire. Practitioner and Specialist credentials are valid for 2 years. Credentials reference the protocol version at time of issuance. See [recertification](#recertification) for how protocol changes affect existing credentials. + +**Learner identity:** Learners authenticate through their AgenticAdvertising.org account (WorkOS). Credentials are tied to authenticated accounts. The program does not currently require proctored identity verification for assessments. + +## Curriculum maintenance + +### Protocol change triggers + +When a protocol version ships (minor or major): + +1. Check `MODULE_RESOURCES` URLs — do any documentation pages move or rename? +2. Review teaching notes — do any key concepts reference behavior that changed? +3. Validate documentation examples against current schemas +4. If a task is added, removed, or renamed, update affected module lesson plans + +### Learner feedback loop + +- Post-completion feedback collected through Addie after every module +- Feedback includes free text and sentiment classification (positive, mixed, negative) +- Patterns in negative feedback trigger curriculum review for the affected module + +### Program evaluation (quarterly) + +Program leadership conducts a quarterly evaluation to assess whether the program is meeting its goals: + +**Data reviewed:** +1. Learner feedback patterns across all modules (sentiment trends, repeated confusion points) +2. Score distributions by module and dimension — consistently low scores indicate a teaching gap +3. Checkpoint data for concepts where learners frequently get stuck +4. Completion rates and time-to-completion trends +5. Credential award rates by tier + +**Process:** +- Program leadership reviews the data and documents findings +- Findings are translated into specific curriculum changes (updated teaching notes, revised lesson plans, adjusted scoring guides) +- Changes are implemented through the standard code review process and tracked via `CODE_VERSION` +- Results of changes are evaluated in the following quarter's review + +### Version tracking + +- Teaching behavior version tracked via `CODE_VERSION` (format: YYYY.MM.N) +- Protocol changes tracked via the changeset workflow +- Score analytics can be compared across config versions to measure teaching improvements + +## Quality assurance + +### Server-side enforcement + +Quality gates are enforced by the application, not by AI judgment alone: + +- Minimum turns and time verified server-side before allowing module completion +- User turn counting is server-side, not client-reported +- Score consistency checks reject completions with >20 point jumps from checkpoint scores +- Module status validation prevents completing modules that aren't in progress +- Credential award checks run automatically after every module completion + +### Feedback and evaluation + +- Structured feedback collected after every module completion +- Sentiment analysis for trend detection across modules and time periods +- Admin analytics: completion rates, score distributions by dimension, time-to-completion +- Organization-level reporting for team credential tracking + +### Continuous improvement + +- Teaching methodology constants reference this framework document as the authoritative source +- Code changes to teaching behavior bump `CODE_VERSION` for before/after comparison +- Quarterly curriculum review driven by learner data, not assumptions + +## Accreditation alignment + +This framework is designed to satisfy the requirements of: + +- **ANSI/IACET 1-2018 Standard for Continuing Education and Training** — Elements 2 (learning environment), 3 (instructional personnel), 5 (learning outcomes), 6 (content and instruction), 7 (assessment), and 9 (evaluation) +- **ASTM E3416-24 Standard Practice for Competency-Based Work-Based Learning Programs** — competency alignment, formative and summative assessment, simulated workplace settings, credential issuance +- **CPD Standards Office** accreditation requirements for continuing professional development + +Organizational policies supporting this framework are documented in the [policies section](/dist/docs/3.0.13/learning/policies/nondiscrimination). diff --git a/dist/docs/3.0.13/learning/overview.mdx b/dist/docs/3.0.13/learning/overview.mdx new file mode 100644 index 0000000000..cac82ac8b7 --- /dev/null +++ b/dist/docs/3.0.13/learning/overview.mdx @@ -0,0 +1,113 @@ +--- +title: AdCP certification program +sidebarTitle: Overview +description: "AdCP certification program: three tiers (Basics, Practitioner, Specialist) taught by Addie, an AI teaching assistant. Free foundational modules, member-only advanced tracks." +"og:title": "AdCP — AdCP certification program" +--- + +# AdCP certification program + +AI is remaking advertising. The question is whether you'll direct the change or react to it. + +AgenticAdvertising.org's vision is a Cre(ai)tive Economy where every brand and creator thrives through agentic collaboration. That economy needs people who understand how these systems work — not just technically, but strategically. + +Agentic advertising is already here — AI agents negotiating media buys, optimizing creative in real time, managing campaigns across channels autonomously. The people who understand how to direct these systems will shape what comes next. Everyone else will be catching up. The certification program exists to build that expertise. + +The AdCP certification program gets you there through interactive, AI-guided modules. No lectures. No multiple-choice tests. You learn by doing — discussing real scenarios, working with live sandbox agents, and building your own advertising agent. + + + Open Addie and say "I want to start the certification program." No account required — the Basics track is free. + + +## What learning with Addie feels like + +Certification happens in conversation. Addie, your AI teaching assistant, adapts to your experience level — asking questions, explaining concepts, walking through real scenarios. It's like having a knowledgeable colleague who never gets impatient. + +Addie assesses your understanding the same way: through conversation. Can you explain concepts clearly? Can you apply them to real scenarios? Can you use the protocol tools correctly? + +The program is designed for anyone in advertising — programmatic traders, media planners, brand managers, agency executives, and engineers alike. No prior programming experience is required. Three modules, about 50 minutes total, and you'll have earned your first credential. Most learners finish the Basics track in a few lunch breaks. + +## Everyone builds + +You don't need to be an engineer to build an advertising agent. + +At the Practitioner level, you'll build a real, working agent — a buyer agent, a seller agent, or a platform agent — using an AI coding assistant like Claude Code or Cursor. This is vibe coding: you describe what you want to build in plain language. The AI coding assistant writes the code. You validate it against real AdCP schemas and iterate on the design with Addie. + +The coding part is fast. The real assessment is whether you can specify what you need, reason about how it works, and improve it through iteration. These are skills every advertising professional should have. + +**By the end of the Practitioner track, you'll have built your own working advertising agent.** + +## Three-tier credential model + + + + **Free and open to everyone.** Complete three foundation modules to demonstrate you understand what agentic advertising is, how AdCP works, and the full protocol landscape. + + Modules: [A1](/dist/docs/3.0.13/learning/foundations/a1-agentic-advertising), [A2](/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture), [A3](/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance) + + + **Basics plus a role track — ending with building your own agent.** Complete the Basics modules and one role track (publisher, buyer, or platform). The track culminates in a build project where you create a working advertising agent. No prior programming experience required. + + Modules: A1–A3 + one track (B1–B4, C1–C4, or D1–D4) + + + **Protocol mastery.** Deep dive into a specific protocol area: media buy, creative, signals, governance, or sponsored intelligence. Combines hands-on lab and adaptive exam. + + Requires: Practitioner + specialist module + + + + +Specialist credentials align with the protocol's domains. Within each domain, AdCP defines **specialisms** — specific flows an agent supports, declared in `get_adcp_capabilities`. Agents demonstrate each specialism by passing its compliance storyboard. The specialist track teaches you to reason about these claims and validate agents against them. See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full taxonomy. + + +## How it works + +Each module follows the same pattern: + +1. **Read** — Review the reading list on this page to build context +2. **Learn** — Start the module with Addie for interactive, Socratic-style teaching +3. **Practice** — Work through exercises against live sandbox agents +4. **Demonstrate** — Show you can do 3–5 specific things that every learner must demonstrate + +Every module has a set of required demonstrations — the same for everyone, regardless of experience. An ad tech veteran and a first-timer both verify the same core competencies. Addie adapts the teaching to your level, but the bar is consistent. See [how we teach](/dist/docs/3.0.13/learning/instructional-design#assessment-fairness) for details. + +## Learning paths + +### Basics (free) + +Everyone starts here. All three modules are free — no membership required. + +| Module | Topic | Duration | Free? | +|--------|-------|----------|-------| +| [A1](/dist/docs/3.0.13/learning/foundations/a1-agentic-advertising) | Why AdCP | 15 min | Yes | +| [A2](/dist/docs/3.0.13/learning/foundations/a2-protocol-architecture) | Your first media buy | 20 min | Yes | +| [A3](/dist/docs/3.0.13/learning/foundations/a3-ecosystem-governance) | The AdCP landscape | 15 min | Yes | + +### Role tracks (choose your path) + +After Basics, choose the track that matches your role. Each track is four modules culminating in a build project. + +| Track | For | Modules | Duration | +|-------|-----|---------|----------| +| [Publisher / seller](/dist/docs/3.0.13/learning/tracks/publisher) | Publishers, SSPs, supply-side platforms | B1–B4 | ~103 min | +| [Buyer / brand](/dist/docs/3.0.13/learning/tracks/buyer) | Brands, agencies, DSPs | C1–C4 | ~92 min | +| [Platform / intermediary](/dist/docs/3.0.13/learning/tracks/platform) | Ad tech platforms, exchanges, data companies | D1–D4 | ~89 min | + +### Specialist modules (protocol mastery) + +Each specialist module combines a hands-on lab with an adaptive exam in a specific protocol area. Requires the Practitioner credential. + +| Specialist | Protocol area | Duration | +|------------|--------------|----------| +| [S1: Media buy](/dist/docs/3.0.13/learning/specialist/media-buy) | Transactions, pricing, multi-agent orchestration | 45 min | +| [S2: Creative](/dist/docs/3.0.13/learning/specialist/creative) | Asset workflows, format compliance, cross-platform adaptation | 45 min | +| [S3: Signals](/dist/docs/3.0.13/learning/specialist/signals) | Signal discovery, activation, privacy, optimization loops | 45 min | +| [S4: Governance](/dist/docs/3.0.13/learning/specialist/governance) | Brand safety, supply chain compliance, content standards | 45 min | +| [S5: Sponsored Intelligence](/dist/docs/3.0.13/learning/specialist/sponsored-intelligence) | Conversational brand experiences, session lifecycle | 45 min | + +## Get started + + + Open Addie and ask to start the AdCP certification program. Addie will guide you through module A1 — no account required. + diff --git a/dist/docs/3.0.13/learning/policies/complaints.mdx b/dist/docs/3.0.13/learning/policies/complaints.mdx new file mode 100644 index 0000000000..78df6c260b --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/complaints.mdx @@ -0,0 +1,60 @@ +--- +title: Complaints and grievances +sidebarTitle: Complaints +description: "AdCP certification complaints policy: how to file grievances, investigation procedures, and resolution timelines for learners and stakeholders." +"og:title": "AdCP — Complaints and grievances" +--- + +# Complaints and grievances + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org program leadership + +## Purpose + +Any learner or stakeholder may raise a concern about the AdCP certification program. This policy describes how complaints are filed, investigated, and resolved. + +## How to file a complaint + +Email certification@agenticadvertising.org with the subject line "Certification complaint" and a description of the concern. + +## What qualifies + +- Assessment fairness concerns +- Accessibility issues +- Content accuracy disputes +- Conduct issues +- Privacy concerns +- Any other matter related to the certification program + +## Timeline + +| Step | Timeframe | +|------|-----------| +| Acknowledgment of receipt | 2 business days | +| Investigation | 10 business days | +| Resolution | 20 business days | + +## Resolution + +A complaint is considered resolved when the program has investigated the concern and communicated the outcome to the complainant. Possible outcomes include: corrective action taken, curriculum updated, policy clarified, or concern determined to be unfounded with explanation provided. + +If the complainant disagrees with the resolution, they may request escalation. + +## Escalation + +**Internal:** If a complaint is not resolved satisfactorily, learners may escalate to AgenticAdvertising.org leadership by replying to the resolution email with a request for escalation. Leadership will review within 10 business days. + +**External:** If internal resolution is unsatisfactory and the complaint involves accreditation standards, learners may contact the relevant accreditation body directly. + +## Confidentiality + +Complaints are handled confidentially. Only those directly involved in the investigation have access to complaint details. + +## No retaliation + +Filing a complaint will not affect a learner's certification status, progress, or access to program materials. + +## Record keeping + +All complaints and resolutions are documented and reviewed quarterly to identify systemic issues and drive program improvements. diff --git a/dist/docs/3.0.13/learning/policies/conflict-of-interest.mdx b/dist/docs/3.0.13/learning/policies/conflict-of-interest.mdx new file mode 100644 index 0000000000..e9f907d244 --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/conflict-of-interest.mdx @@ -0,0 +1,39 @@ +--- +title: Conflict of interest +sidebarTitle: Conflict of interest +description: "AdCP certification conflict of interest policy: disclosure requirements and separation of commercial interests from curriculum and assessment decisions." +"og:title": "AdCP — Conflict of interest" +--- + +# Conflict of interest + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org program leadership + +## Purpose + +This policy ensures that the AdCP certification program maintains objectivity and that commercial interests do not influence curriculum content or assessment outcomes. + +## Disclosure requirements + +Anyone involved in curriculum design, assessment criteria definition, or scoring calibration must disclose: + +- Employment by or investment in companies whose products are covered in the curriculum +- Advisory relationships with AgenticAdvertising.org member organizations +- Financial interest in certification outcomes + +Disclosures are made to program leadership and reviewed before the contributor participates in curriculum decisions. + +## Separation of interests + +- Curriculum content is based on the open AdCP protocol specification, not any single vendor's implementation +- No module promotes a specific commercial product or service +- Assessment rubrics are defined before instruction begins and applied consistently across all learners + +## AI instruction + +Addie follows operational rules defined by the curriculum team. Teaching methodology is documented in the [instructional design framework](/dist/docs/3.0.13/learning/instructional-design) and applied uniformly. Commercial interests do not influence how Addie teaches or scores. + +## Non-compliance + +Undisclosed conflicts result in review of affected curriculum content and potential removal from the curriculum contributor role. diff --git a/dist/docs/3.0.13/learning/policies/intellectual-property.mdx b/dist/docs/3.0.13/learning/policies/intellectual-property.mdx new file mode 100644 index 0000000000..7d0b8c9bb4 --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/intellectual-property.mdx @@ -0,0 +1,43 @@ +--- +title: Intellectual property +sidebarTitle: Intellectual property +description: "AdCP certification intellectual property policy: ownership and usage rights for curriculum materials, assessments, and learner-created content." +"og:title": "AdCP — Intellectual property" +--- + +# Intellectual property + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org leadership + +## Purpose + +This policy clarifies ownership and usage rights for materials created and used in the AdCP certification program. + +## Course materials + +Curriculum content — including lesson plans, assessment criteria, teaching notes, and exercises — is owned by AgenticAdvertising.org. These materials may not be reproduced or distributed without permission. + +## Protocol specification + +AdCP is an open protocol. The certification program teaches the protocol; it does not grant or restrict rights to implement it. Completing certification does not confer any intellectual property rights over the protocol specification. + +## Learner-created content + +Code and configurations created during build project modules (B4, C4, D4) remain the learner's property. AgenticAdvertising.org does not claim ownership of learner implementations. + +## Conversation content + +Teaching conversations may be used in anonymized, aggregated form to improve the curriculum. Individual conversations are not published or shared. + +## Credential marks + +The AdCP Basics, Practitioner, and Specialist credential names and associated badges are trademarks of AgenticAdvertising.org. Credential holders may display their earned credentials in professional contexts. + +## Third-party content + +Learning resources linked in modules point to the official AdCP documentation. Third-party trademarks referenced in curriculum examples belong to their respective owners. + +## Non-compliance + +Unauthorized use of course materials or credential marks is subject to standard trademark and copyright enforcement. diff --git a/dist/docs/3.0.13/learning/policies/learner-records.mdx b/dist/docs/3.0.13/learning/policies/learner-records.mdx new file mode 100644 index 0000000000..ed02089989 --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/learner-records.mdx @@ -0,0 +1,56 @@ +--- +title: Learner records and privacy +sidebarTitle: Learner records +description: "AdCP certification learner records policy: data retention, privacy protections, transcript access rights, and deletion procedures for program participants." +"og:title": "AdCP — Learner records and privacy" +--- + +# Learner records and privacy + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org engineering and program leadership + +## Purpose + +This policy establishes how learner data is collected, stored, accessed, and retained in the AdCP certification program. + +## What we store + +For each learner who participates in the certification program: + +- Identity (name, email, organization) +- Module progress and status (not started, in progress, completed, tested out) +- Completion dates +- Assessment scores (per-dimension, internal only) +- Credentials earned and award dates +- Teaching checkpoints (concepts covered, learner strengths and gaps, preliminary scores) +- Post-completion feedback + +## Where data is stored + +Learner records are maintained in two systems: + +- **Application database** (PostgreSQL) — progress, scores, checkpoints, feedback +- **Certifier** — credential badges, verification URLs, expiry tracking + +## Retention + +Learner records are retained for a minimum of **7 years** from the date of last activity, consistent with IACET accreditation requirements. + +## Transcript access + +Learners can view their progress and earned credentials at any time through the certification dashboard. Each credential includes a unique verification URL and QR code for third-party verification. + +## Privacy + +- Assessment scores are internal and never shared publicly. Only credential status (earned or not earned) is visible to others +- Teaching conversations may be used in anonymized, aggregated form to improve the curriculum. Individual conversations are not published or shared +- Learner feedback is reviewed in aggregate for curriculum improvement; individual feedback is not attributed publicly + + +**GDPR:** Learners may request data export or deletion by contacting certification@agenticadvertising.org. Deletion requests are honored except where retention is required for accreditation compliance (7-year minimum). In such cases, records are anonymized rather than deleted. + + +## Non-compliance + +Data handling violations are escalated to program leadership with corrective action within 5 business days. diff --git a/dist/docs/3.0.13/learning/policies/nondiscrimination.mdx b/dist/docs/3.0.13/learning/policies/nondiscrimination.mdx new file mode 100644 index 0000000000..0071b85592 --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/nondiscrimination.mdx @@ -0,0 +1,36 @@ +--- +title: Nondiscrimination policy +sidebarTitle: Nondiscrimination +description: "AdCP certification nondiscrimination policy: equal access, inclusion standards, and accommodation procedures for all learners regardless of protected characteristics." +"og:title": "AdCP — Nondiscrimination policy" +--- + +# Nondiscrimination policy + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org program leadership + +## Purpose + +AgenticAdvertising.org is committed to providing equal access to the AdCP certification program for all learners regardless of race, color, national origin, gender, age, disability, religion, sexual orientation, veteran status, genetic information, or any other protected characteristic. + +## Standards + +- All certification modules are available to any learner who meets the stated prerequisites +- AI-delivered instruction applies the same teaching methodology and assessment criteria to every learner +- Assessment is based solely on demonstrated knowledge and competency against published rubric dimensions +- No learner is advantaged or disadvantaged based on personal characteristics + +## Accessibility + +- Instruction is delivered through web-based text conversation, compatible with screen readers and assistive technology +- There are no time-pressure assessments — the program uses mastery-based progression, allowing each learner the time they need +- Learners who need accommodations may contact certification@agenticadvertising.org + +## Language + +Instruction is delivered in English. Learners may ask for clarification using their preferred terminology. Addie adapts to each learner's communication style and level of technical familiarity. + +## Non-compliance + +Violations of this policy should be reported through the [complaints process](/dist/docs/3.0.13/learning/policies/complaints). Reports are investigated within 10 business days. diff --git a/dist/docs/3.0.13/learning/policies/personnel-qualifications.mdx b/dist/docs/3.0.13/learning/policies/personnel-qualifications.mdx new file mode 100644 index 0000000000..9464599f64 --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/personnel-qualifications.mdx @@ -0,0 +1,42 @@ +--- +title: Personnel qualifications +sidebarTitle: Personnel qualifications +description: "AdCP certification personnel qualifications: standards for curriculum designers, content reviewers, and the Addie AI teaching assistant." +"og:title": "AdCP — Personnel qualifications" +--- + +# Personnel qualifications + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org program leadership + +## Purpose + +This policy establishes qualification standards for everyone involved in designing, reviewing, and delivering the AdCP certification program. + +## Curriculum design + +Module lesson plans, assessment criteria, and scoring rubrics are designed by subject matter experts with demonstrated expertise in advertising technology and/or the AdCP protocol. Expertise is established through direct involvement in protocol development, industry experience, or both. + +## Content review + +Curriculum changes are reviewed through the standard code review process (pull requests) before deployment. Protocol accuracy is validated against the AdCP specification. Changes that do not meet review standards are not deployed. + +## AI teaching assistant + +Addie is powered by Claude (Anthropic). Teaching behavior is governed by operational rules documented in the [instructional design framework](/dist/docs/3.0.13/learning/instructional-design), covering: + +- Socratic methodology and turn structure +- Assessment fairness and scoring calibration +- Learner data handling and privacy +- When and how to use tools for demos and exercises + +Addie cannot award credentials without meeting server-side validation requirements: minimum engagement time, minimum conversational turns, checkpoint requirements, and score consistency checks. These are enforced by the application, not by AI judgment alone. + +## Ongoing qualification + +Curriculum contributors stay current through participation in the AdCP working group, protocol development, and quarterly curriculum reviews. + +## Non-compliance + +Teaching behavior changes require a `CODE_VERSION` bump to enable performance comparison. Curriculum changes that bypass the review process are reverted. diff --git a/dist/docs/3.0.13/learning/policies/refund.mdx b/dist/docs/3.0.13/learning/policies/refund.mdx new file mode 100644 index 0000000000..30be6fb5c7 --- /dev/null +++ b/dist/docs/3.0.13/learning/policies/refund.mdx @@ -0,0 +1,33 @@ +--- +title: Refund and cancellation +sidebarTitle: Refund +description: "AdCP certification refund and cancellation policy: terms for membership fees, module access, and credential issuance." +"og:title": "AdCP — Refund and cancellation" +--- + +# Refund and cancellation + +**Adopted:** March 2026 +**Responsible party:** AgenticAdvertising.org program leadership + +## Purpose + +This policy describes refund and cancellation terms for the AdCP certification program. + +## Certification program fees + +The Basics tier (modules A1-A3) is free and open to everyone. No payment is required. + +The Practitioner and Specialist tiers require an AgenticAdvertising.org membership. Membership fees and refund terms are governed by the membership agreement, not this policy. + +## Certification-specific refunds + +There are no separate fees for certification modules, exams, or credential issuance beyond the membership requirement. Because there is no certification-specific charge, there is no certification-specific refund. + +## Credential revocation + +Credentials are not revoked except in cases of verified fraud (e.g., identity misrepresentation). Credential expiry and renewal follow the terms described in the [instructional design framework](/dist/docs/3.0.13/learning/instructional-design#credential-issuance). + +## Questions + +Contact certification@agenticadvertising.org for questions about program fees or membership terms. diff --git a/dist/docs/3.0.13/learning/specialist/creative.mdx b/dist/docs/3.0.13/learning/specialist/creative.mdx new file mode 100644 index 0000000000..9a103d15cb --- /dev/null +++ b/dist/docs/3.0.13/learning/specialist/creative.mdx @@ -0,0 +1,184 @@ +--- +title: "S2: Creative mastery" +sidebarTitle: "S2: Creative" +description: "AdCP specialist module S2: Creative mastery. Format taxonomy across 20 channels, creative manifest spec, AI generation, compliance checks, and asset sync workflows." +"og:title": "AdCP — S2: Creative mastery" +--- + +# S2: Creative mastery + + +**Members only** — Requires Practitioner credential. ~45 minutes with Addie. Combines hands-on lab and adaptive exam. + + +This specialist module tests your mastery of the creative protocol. You'll work with sandbox agents to discover format requirements across channels, produce and adapt creative assets, sync them to publishers, and verify compliance. Addie evaluates both your hands-on work and your conceptual understanding. + +Passing earns the **AdCP specialist — Creative** credential. + +## Specialisms this track prepares you to validate + +The following `specialisms` fall under the `creative` domain. Each has its own compliance storyboard — see the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full taxonomy. + +| Specialism | Status | What it covers | +|---|---|---| +| `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 | + +## What you'll demonstrate + +- Identify the three creative agent interaction models and explain when each applies +- Work with stateless agents (template/transformers): discover formats, preview with inline assets, build serving tags +- Work with stateful ad servers: browse a creative library, generate tags per media buy, track delivery +- Work with stateful sales agents: push creatives, preview in the publisher's environment +- Adapt a single creative concept across multiple channels and formats +- Use `preview_creative` in all three request modes — single, batch (5–10× faster for many creatives), and variant (retrieve historical renders from `get_creative_delivery`) — and choose `output_format: "html"` when bypassing the iframe speeds rendering +- Understand creative agent pricing: the `pricing_options[]` array on discovery responses, how `pricing_option_id` flows through `build_creative` and `report_usage`, and why CPM ad servers show zero `vendor_cost` at build time while transformation agents do not +- Reason about tracker-slot presence — a format supports third-party measurement only if its `assets` array declares a tracker slot (e.g., `impression_tracker`). Broadcast formats intentionally omit tracker slots; measurement comes from panel and STB data via `billing_measurement` +- Attach `industry_identifiers[]` to broadcast manifests with the correct `creative-identifier-type` (`ad_id`, `isci`, `clearcast_clock`), and give each cut (`:15` vs `:30`) its own Ad-ID +- Reason about format edge cases, accessibility, and provenance + +## Prerequisite reading + +### Core creative tasks + + + + Format discovery: what specs does each publisher require? + + + Creative generation and transformation from brand assets. + + + Preview creatives before deployment. + + + Synchronize creative assets with publisher platforms. + + + +### Supporting concepts + + + + The creative protocol: assets, formats, manifests, and creative agents. + + + Formal specification for the creative protocol. + + + Format definitions, technical specs, and the renders structure. + + + Images, video, audio, HTML5, and native asset specifications. + + + Manifest structure: how creative packages describe their contents. + + + Channel-specific specs for video, display, audio, DOOH, and carousels. + + + Broadcast formats, `industry_identifiers[]` (`ad_id`, `isci`, `clearcast_clock`), Ad-ID integration, and why broadcast formats omit tracker slots. + + + AI-generated creative workflows and best practices. + + + Accessibility standards for advertising creatives. + + + AI creative provenance and disclosure requirements. + + + Architecture guide for building creative agents. + + + +### Creative governance + + + + Feature-based creative evaluation: security scanning, regulatory compliance, and content categorization. + + + The evaluation task that scores creatives against governance features. + + + Buyer-defined content rules that constrain what creatives can contain. + + + How governance agents verify AI creative provenance metadata. + + + +## Connecting to the test agent + +Lab exercises run against the public test agent. Use the shared token — no signup required: + +```bash +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +See the [Quickstart](/dist/docs/3.0.13/quickstart) for a walkthrough of your first call. + +## Lab exercises + +### Interaction models + +1. **Stateless transformation** — Connect to a template agent, discover its formats, preview a template with sample brand assets (inline), and build a serving tag. No `sync_creatives` — everything is passed in the call. +2. **Ad server workflow** — Connect to a pre-loaded ad server, browse the creative library with `list_creatives`, generate tags for multiple media buys using `build_creative`, and check delivery metrics. +3. **Push-and-preview** — Connect to a sales agent, discover its accepted formats, push catalog assets with `sync_creatives`, and preview how they render in the publisher's environment. + +### Creative pricing + +4. **Pricing lifecycle** — Using the same sandbox ad server from exercise 2: + - Establish an account with the ad server + - `list_creatives` with `account` and `include_pricing: true` — observe `pricing_options` on each creative reflecting your rate card + - Switch to a second sandbox account, `list_creatives` again — observe that `pricing_options` reflects a different rate (different CPM, model, or currency) + - `build_creative` with `account` — examine `pricing_option_id`, `vendor_cost`, and `consumption` in the response + - `build_creative` without `account` — observe the rejection error + - Call `report_usage` with `creative_id` + `pricing_option_id`, verify the values match what `build_creative` returned + - Explain: why is `vendor_cost` zero on a CPM-priced creative at build time? + +5. **Transformation agent pricing** — Using the sandbox transformation agent from exercise 1, with pricing enabled: + - Establish an account with the transformation agent + - `list_creative_formats` with `account` and `include_pricing: true` — observe `pricing_options` on each format (per-unit pricing) + - `build_creative` with `account` — examine `pricing_option_id`, `vendor_cost`, and `consumption` (expect non-zero `vendor_cost` at build time, unlike the CPM ad server) + - Compare: ad server discovers pricing on `list_creatives`, transformation agent discovers pricing on `list_creative_formats`. Ad server has zero build cost (CPM accrues at serve time), transformation agent has non-zero build cost (per-unit pricing) + + +**Vendor pricing is consistent across protocols** + +All vendor services use the same pattern: `pricing_options[]` on discovery responses, `pricing_option_id` in `report_usage`. Signals, content standards, creative agents, and property list agents all follow this. + +Vendors often offer multiple pricing options per creative — volume/commitment tiers (lower CPM at higher spend), context-specific rates (premium vs. standard placements), or different pricing models for different product lines (CPM for rich media, per-unit for social variants). The buyer selects the appropriate `pricing_option_id` and passes it in `report_usage`. + + +### Cross-platform skills + +6. **Format discovery** — Query sandbox agents for supported formats, compare requirements across publishers +7. **Cross-platform adaptation** — Adapt one concept across display, video, and native formats using `build_creative` with `target_format_ids` +8. **Compliance** — Configure provenance metadata and disclosure requirements for AI-generated creatives +9. **Preview modes** — Run `preview_creative` as `request_type: "single"`, then `"batch"` (submit 5 creatives in one call and measure the speedup), then `"variant"` against a prior `get_creative_delivery` result. Compare `output_format: "url"` vs `"html"` for rendering latency. +10. **Tracker-slot audit** — For each sandbox format, inspect the `assets` array and determine whether it supports third-party measurement. Explain why assigning a DoubleVerify pixel to a broadcast spot won't work, and which `billing_measurement` vendor would instead. +11. **Broadcast identifiers** — Build a broadcast manifest with distinct `industry_identifiers[]` for `:15` and `:30` cuts of the same spot. Verify the `creative-identifier-type` values and explain why each cut needs its own Ad-ID. + +## Assessment + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Interaction models | 20% | Correctly identifies and works with all three creative agent types | +| Cross-platform | 25% | Adapts creatives across channels and formats | +| Compliance | 25% | Configures disclosures, provenance, and regulatory requirements | +| Pricing and accounts | 15% | Understands rate cards, reads pricing from `list_creatives` and `list_creative_formats`, interprets build costs, closes the `report_usage` loop | +| Analytical skill | 15% | Interprets creative feature evaluation and delivery results | + +Passing threshold: 70%. + +## Start this module + + + "I'd like to start the creative specialist module." + diff --git a/dist/docs/3.0.13/learning/specialist/governance.mdx b/dist/docs/3.0.13/learning/specialist/governance.mdx new file mode 100644 index 0000000000..0b9bc9e893 --- /dev/null +++ b/dist/docs/3.0.13/learning/specialist/governance.mdx @@ -0,0 +1,182 @@ +--- +title: "S4: Governance" +sidebarTitle: "S4: Governance" +description: "AdCP specialist module S4: Governance protocol mastery. Content standards, property lists, campaign governance lifecycle, policy registry, and compliance automation with sandbox agents." +"og:title": "AdCP — S4: Governance" +--- + +# S4: Governance + + +**Members only** — Requires Practitioner credential. ~60 minutes with Addie. Combines hands-on lab and adaptive exam. + + +This specialist module tests your mastery of the full governance protocol. You'll work with sandbox agents to manage content standards, property lists, and collection lists, execute the campaign governance lifecycle (plans, validation, outcomes, audit), resolve compliance policies from the registry, and reason about how governance domains compose. Addie evaluates both your hands-on work and your conceptual understanding. + +Passing earns the **AdCP specialist — Governance** credential. + +## Specialisms this track prepares you to validate + +The following `specialisms` fall under the `governance` domain. Each has its own compliance storyboard — see the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full taxonomy. + +| Specialism | Status | What it covers | +|---|---|---| +| `content-standards` | stable | Content standards enforcement (brand safety, policy compliance) | +| `property-lists` | stable | Curated inclusion and exclusion lists for targeting and delivery compliance | +| `collection-lists` | stable | Curated inclusion and exclusion lists of content programs (shows, series, podcasts) | +| `audience-sync` | stable | Syncs buyer-provided audience segments into a platform for activation | +| `governance-delivery-monitor` | stable | Campaign delivery monitoring with drift detection | +| `governance-spend-authority` | stable | Conditional spend approval and human-in-the-loop governance | + +## What you'll demonstrate + +- Create, update, list, and delete content standards +- Create, update, list, and delete property lists +- Create, update, list, and delete collection lists — program-level brand safety across platforms +- Calibrate content against standards and interpret results +- Create and validate campaign plans with budget authority and policy configuration +- Sync governance agents to accounts via `sync_governance` +- Execute the full governance loop: `sync_plans`, `check_governance` (intent + execution), `report_plan_outcome` +- Use the policy registry to resolve and apply compliance policies +- Interpret audit logs and reason about drift metrics +- Distinguish the three layers of brand safety: + - **Property lists** — *where* ads run. Stateful lists of properties (domains, apps, CTV services) with include/exclude semantics. Filter supply at the inventory level. + - **Collection lists** — *what content* ads run in. Shows, series, movies, and sports programs identified by distribution IDs (Gracenote SH/MV/SP). Program-level exclusions independent of which property carries them. + - **Content standards** — *per-impression adjacency*. Buyer-defined rules that evaluate actual content (not metadata) at serve time via calibrate → execute → validate. Use for episode-level or ephemeral content. +- Explain when to use each layer alone and how they compose (most buyers use one or two) +- Explain how governance domains compose — campaign, property, collection, content standards, creative +- Handle `GOVERNANCE_DENIED` end-to-end: read the `governance_context`, identify the failed rule, correct the payload, and retry +- Walk through the 15-step seller verification of a signed `governance_context` (JWS): `alg` allowlist, `typ` match, JWKS resolution via SSRF-validated fetch, `aud`/`sub`/`phase` binding, `jti` replay dedup, revocation-list check. Explain why each step closes a specific attack (spoofed issuer, plan swap, token replay, key compromise). +- Apply governance across `purchase_type` values: `media_buy`, `rights_license`, `signal_activation`, `creative_services`. All share the same loop; media-buy-specific validations (channel compliance, seller concentration, pacing) apply only when the payload contains the relevant fields +- Explain the `governance_context` correlation model: governance agent issues an opaque token on first check; buyer attaches it to the media buy envelope; seller echoes it on execution checks so the agent reconnects each lifecycle event without re-deriving state +- Configure audience constraints on a campaign plan using policy categories and restricted attributes +- Validate that `check_governance` correctly denies targeting that violates audience constraints + +## Prerequisite reading + +### Core governance tasks + + + + Create and manage property lists for inclusion/exclusion filtering. + + + Create and manage collection lists for program-level brand safety. + + + Define brand-specific content standards for automated compliance. + + + Test whether content meets defined standards before placement. + + + Verify that ads were delivered to authorized properties. + + + Sync governance agent endpoints to accounts for seller-side validation. + + + Create and update campaign plans that define authorized parameters. + + + Validate intended and executed actions against the campaign plan. + + + Report confirmed outcomes and commit budget against the plan. + + + Review governance decisions, findings, and drift metrics. + + + +### Supporting concepts + + + + The governance protocol: property, content standards, creative, and campaign governance. + + + Multi-party validation, budget authority, and the trust model. + + + Three-party trust model: orchestrator, governance agent, and seller. + + + Full technical specification: validation categories, budget tracking, and audience governance. + + + Community-maintained compliance policies: regulations (COPPA, GDPR, HFSS) and standards (alcohol, pharma). + + + Identity, authorization, and data enrichment for publishers. + + + Program-level brand safety with collection lists across CTV, podcast, and other media. + + + How content standards work: calibration, local execution, and validation. + + + Implementation guide for content standards. + + + Artifacts generated during content evaluation. + + + Creative quality, compliance, and feature analysis. + + + Verifying AI creative provenance and disclosure. + + + Analyzing creative features for compliance evaluation. + + + Post-delivery content validation. + + + Retrieving evaluation artifacts for audit and review. + + + +## Connecting to the test agent + +Lab exercises run against the public test agent. Use the shared token — no signup required: + +```bash +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +See the [Quickstart](/dist/docs/3.0.13/quickstart) for a walkthrough of your first call. + +## Lab exercises + +1. **Content standards lifecycle** — Create, update, and manage content standards for a fictional brand +2. **Property list management** — Create inclusion and exclusion lists for supply path control +3. **Collection list management** — Create a do-not-air collection list with distribution identifiers, content rating filters, and genre exclusions. Understand how collection lists compose with property lists and content standards for three-layer brand safety. +4. **Content calibration** — Calibrate sample content against your standards and interpret results +5. **Compliance verification** — Verify that sandbox campaign deliveries meet governance requirements +6. **Campaign governance lifecycle** — Sync governance agents via `sync_governance`, create a campaign plan, validate actions (intent + execution), handle denials and conditions, report outcomes, and review audit logs +7. **GOVERNANCE_DENIED recovery** — Submit a `check_governance` request that fails (audience uses a restricted attribute, or media buy exceeds committed budget). Read the denial reason, correct the payload, and verify the retry passes. Trace `governance_context` through the orchestrator → seller execution check to prove the correlation model. +8. **Purchase type variation** — Run `check_governance` with `purchase_type: "rights_license"` and `purchase_type: "signal_activation"` on the same plan. Observe which validations apply uniformly (budget, geo, flight) and which are media-buy-specific. +9. **Policy resolution and compliance** — Resolve policies from the registry, configure jurisdiction-scoped enforcement, verify that violations are caught +10. **Audience governance** — Configure a plan with `policy_categories` (e.g., `fair_housing`) that carry `restricted_attributes`. Submit `check_governance` requests with audience selectors that use restricted signals and verify the governance agent denies them. Then submit compliant targeting and confirm approval. +11. **Audience drift detection** — Run a series of delivery-phase governance checks with `audience_distribution` indices that gradually shift from baseline parity. Observe how cumulative indices reveal sustained bias patterns that single-period noise obscures, and how governance findings escalate when drift exceeds thresholds. + +## Assessment + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Protocol mastery | 25% | Full governance protocol mastery across all domains including campaign governance lifecycle | +| Safety expertise | 25% | Understands the three-party trust model, separation of duties, and how governance domains compose | +| Oracle understanding | 20% | Understands AI-driven evaluation models and policy registry integration | +| Compliance skill | 30% | Handles regulatory requirements, policy resolution, jurisdiction scoping, and audit interpretation | + +Passing threshold: 70%. + +## Start this module + + + "I'd like to start the governance specialist module." + diff --git a/dist/docs/3.0.13/learning/specialist/media-buy.mdx b/dist/docs/3.0.13/learning/specialist/media-buy.mdx new file mode 100644 index 0000000000..f66bc990c1 --- /dev/null +++ b/dist/docs/3.0.13/learning/specialist/media-buy.mdx @@ -0,0 +1,151 @@ +--- +title: "S1: Media buy mastery" +sidebarTitle: "S1: Media buy" +description: "AdCP specialist module S1: Media buy mastery. Full transaction lifecycle, pricing models, proposals, forecasting, and multi-agent orchestration with live sandbox agents." +"og:title": "AdCP — S1: Media buy mastery" +--- + +# S1: Media buy mastery + + +**Members only** — Requires Practitioner credential. ~45 minutes with Addie. Combines hands-on lab and adaptive exam. + + +This specialist module tests your mastery of the media buy transaction lifecycle. You'll work with live sandbox agents to execute complex flows: proposals, forecasting, refinement, packages, and multi-agent orchestration. Addie evaluates both your hands-on work and your conceptual understanding. + +Passing earns the **AdCP specialist — Media buy** credential. + +## Specialisms this track prepares you to validate + +Agents in the `media_buy` domain declare specific flows they support via the `specialisms` field on `get_adcp_capabilities`. Each specialism has a compliance storyboard at `/compliance/{version}/specialisms/{id}/` that a runner executes to verify the claim. This module prepares you to reason about and validate agents against these claims: + +| Specialism | Status | What it covers | +|---|---|---| +| `sales-guaranteed` | stable | Guaranteed media buys with human IO approval | +| `sales-non-guaranteed` | stable | Non-guaranteed auction-based media buys | +| `sales-proposal-mode` | stable | Media buys negotiated via proposal acceptance | +| `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 | + +See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full taxonomy and the [`specialism` enum](https://adcontextprotocol.org/schemas/3.0.13/enums/specialism.json) for the authoritative list. + +## What you'll demonstrate + +- Execute the full media buy lifecycle including proposals and forecasting +- Explain why every mutating media-buy request is cryptographically signed (RFC 9421): the signature is what lets the seller authenticate the buyer and detect tampering. No amount of lifecycle logic matters if you can't trust who sent the request. +- Walk the buyer-identity resolution chain: signature → JWKS → agent entry → brand.json. Explain why `iss` claims and client-supplied headers are never treated as identity, and what each link in the chain defends against. +- Apply `idempotency_key` correctly across the lifecycle: fresh UUID v4 per logical buy, same key + same payload on network retry (returns `replayed: true`), new key when the agent re-plans with a different payload, and the handling of `IDEMPOTENCY_CONFLICT` and `IDEMPOTENCY_EXPIRED`. Explain why this is what makes agent retries safe for real money. +- Trace the state machine: `create_media_buy` returns `pending_creatives` or `pending_start`; `sync_creatives` clears `pending_creatives`; the seller MUST transition `pending_start` → `active` at flight start and webhook the orchestrator. Seller-initiated `rejected` is only valid from the pending states. +- Reason about which actions are valid in which states (`cancel`, `sync_creatives` from pending states; `pause`/`resume`/`update_media_buy` from `active`), and handle `NOT_CANCELLABLE` and concurrency conflicts via `revision` +- Select a `pricing_option_id` from a product's `pricing_options[]` array — CPM, vCPM, CPP, CPA, flat rate, time — and explain why different pricing models carry different `parameters` +- Negotiate `measurement_terms` and `performance_standards` on guaranteed buys: propose overrides on `create_media_buy`, interpret seller acceptance (echoed back), adjustments, or `TERMS_REJECTED`. Recover by aligning to the seller's supported vendors or accepting product defaults. +- Know that `update_media_buy` requires `account` (not just `media_buy_id`) so billing routes to the right relationship; omitting it is a protocol error +- Tie broadcast buys to agency billing by attaching `agency_estimate_number` at the buy or package level (package-level overrides buy-level when flights or stations differ) +- Use `get_media_buys` to check status, `valid_actions`, and creative approvals before acting +- Handle pricing negotiation, budget allocation, and multi-agent orchestration +- Use refinement and package requests for complex buying scenarios +- Monitor and optimize campaign delivery using protocol tools +- Reason about failure modes, conflict resolution, and edge cases + +## Prerequisite reading + +### Core transaction tasks + + + + Product discovery: natural language briefs, structured filters, response schemas. + + + Campaign creation: manual mode, proposal mode, approval lifecycle. + + + Campaign modification: budgets, targeting, scheduling, creative swaps. + + + Operational status: lifecycle state, creative approvals, valid actions, delivery snapshots. + + + Delivery reporting: impressions, spend, completion rates, performance. + + + +### Supporting concepts + + + + The formal specification for the media buy protocol. + + + `pricing_options[]` and `pricing_option_id`: CPM, vCPM, CPP, CPA, flat rate, time. + + + Negotiate `performance_standards`, `measurement_terms`, and `cancellation_policy`. Recovery from `TERMS_REJECTED`. + + + Campaign structure, the `pending_creatives` → `pending_start` → `active` state machine, and the approval lifecycle. + + + How TMP handles impression-time decisions such as cross-publisher frequency capping. + + + How campaign workflows in AdCP connect to impression-time execution patterns. + + + Architecture patterns for multi-agent orchestration. + + + Event sources, log_event, and attribution setup. + + + Seller optimization feedback based on campaign performance. + + + The two structurally separated operations that power impression-time execution. + + + Deployment, fan-out, and provider configuration for the TMP Router. + + + +## Connecting to the test agent + +Lab exercises run against the public test agent. Use the shared token — no signup required: + +```bash +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +See the [Quickstart](/dist/docs/3.0.13/quickstart) for a walkthrough of your first call. + +## Lab exercises + +During the module, Addie will guide you through hands-on exercises: + +1. **Product discovery and evaluation** — Query multiple sandbox agents, compare products, evaluate pricing +2. **Pricing option selection** — From the same product's `pricing_options[]` array, select a CPM option and a CPP option. Explain what each option's `parameters` mean and why the minimum spend differs. +3. **Proposal and forecasting** — Request proposals, analyze delivery forecasts (spend curves and availability) +4. **Terms negotiation** — On a guaranteed product, propose `measurement_terms` with a different vendor than the seller's default, and `performance_standards` with a tighter viewability threshold. Observe seller acceptance, adjustment, or `TERMS_REJECTED`. Recover by aligning to the seller's supported vendors. +5. **Campaign creation and optimization** — Create a media buy, monitor delivery, execute updates. Verify `update_media_buy` fails without `account` and succeeds with it. +6. **State machine walkthrough** — Create a buy and trace it through `pending_creatives` (no creatives attached) → `sync_creatives` → `pending_start` → `active` at flight start. Use `get_media_buys` at each step to read `status`, `valid_actions`, and transition timestamps. Force a seller rejection from `pending_start` via the sandbox controller and verify the terminal state. +7. **Lifecycle management** — Check `valid_actions`, cancel a package, handle `NOT_CANCELLABLE`, use `revision` for concurrency +8. **Broadcast billing** — Create a broadcast buy with a buy-level `agency_estimate_number` and one package that overrides it with a station-specific estimate number. Verify both appear on delivery reconciliation. +9. **Multi-agent orchestration and execution** — Manage campaigns across multiple sellers. Trace a cross-publisher suppression scenario: a viewer sees an ad on publisher A, then visits publisher B within the 2-hour recency window — what does Identity Match return and why? Configure frequency parameters (5/week, 2-hour minimum recency) and predict delivery impact. Explain why Context Match and Identity Match are structurally separated. + +## Assessment + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Protocol mastery | 30% | Comprehensive understanding of media buy lifecycle | +| Targeting expertise | 25% | Masters advanced targeting capabilities | +| Analytical skill | 25% | Analyzes delivery data effectively | +| Problem solving | 20% | Handles complex scenarios and edge cases | + +Passing threshold: 70%. + +## Start this module + + + "I'd like to start the media buy specialist module." + diff --git a/dist/docs/3.0.13/learning/specialist/security.mdx b/dist/docs/3.0.13/learning/specialist/security.mdx new file mode 100644 index 0000000000..42338181cf --- /dev/null +++ b/dist/docs/3.0.13/learning/specialist/security.mdx @@ -0,0 +1,103 @@ +--- +title: "S6: Security" +sidebarTitle: "S6: Security" +description: "AdCP specialist module S6: Security mastery. Threat model, 5-layer defense model, idempotency semantics, governance token verification, SSRF discipline, and operational incident response." +"og:title": "AdCP — S6: Security" +--- + +# S6: Security + + +**Members only** — Requires Practitioner credential. ~60 minutes with Addie. Combines hands-on lab and adaptive exam. + + +This specialist module tests your mastery of AdCP's security model. You'll work with sandbox agents to demonstrate the protocol's five-layer defense in practice: identity verification, tenant isolation, idempotency semantics, signed governance verification, and SSRF discipline. Addie evaluates both hands-on execution and your ability to reason about threat scenarios and incident response. + +Passing earns the **AdCP specialist — Security** credential. + + +This module is in development and not yet available via Addie. The curriculum, sandbox exercises, and teaching flow are being finalized. Check back soon. + + + +This module covers AdCP-specific controls — the threat model, layered defenses, and operational response patterns specific to agentic advertising systems. It is not a replacement for a general security program. Certified specialists can reason about how AdCP's controls compose; for OWASP Top 10 or general security engineering, see your organization's security training. + + +## Specialisms this track prepares you to validate + +The following `specialisms` fall under the security domain. Each has its own compliance storyboard — see the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full taxonomy. + +| Specialism | Status | What it covers | +|---|---|---| +| `security` | stable | Authentication baseline — unauth rejection, API key enforcement, OAuth discovery + RFC 9728 audience binding | +| `signed-requests` | stable | RFC 9421 transport-layer request-signing verification | + +## What you'll demonstrate + +- Explain the agentic advertising threat model: credential theft, replay attacks, cross-tenant data leakage, SSRF on outbound fetches, spoofed agent identity, unauthorized governance token use, and audit log tampering +- Walk through AdCP's 5-layer defense model — identity, isolation, idempotency, signed governance, auditability — and name the specific attack each layer closes +- Mint an idempotency key and demonstrate all four outcomes against a sandbox: successful first call, idempotent replay (`replayed: true`), conflict on payload change, and expiry on TTL lapse. Explain the side-effect implications of each outcome. +- Verify a signed governance token against a JWKS endpoint: walk the 15-step verification checklist (`alg` allowlist, `typ` match, SSRF-validated JWKS fetch, `aud`/`sub`/`phase` binding, `jti` replay dedup, revocation list check), tamper with a claim and observe rejection, trace the revocation mechanics +- Implement the 6-point SSRF check on an outbound fetch: HTTPS-only enforcement, reserved-IP deny list (including cloud metadata endpoints), IP-pin validation, redirect suppression, size and timeout caps, and suppressed error detail in responses +- Design an operational runbook covering credential compromise, webhook secret rotation, governance key revocation, and cross-party incident communication +- Given an incident description, identify which defense layer failed and what specific control to harden + + +S4 (Governance) covers the 15-step JWS seller verification from the **seller's** perspective — how a seller validates a governance token issued by a buyer's governance agent. S6 covers it from the **security operator's** perspective — verifying your own token issuance implementation is correct and reasoning about what each step closes. Overlap is intentional; the framing is different. + + +## Prerequisite reading + + + + AdCP's five-layer defense model: identity, isolation, idempotency, signed governance, and auditability. + + + Implementation reference: idempotency enforcement, webhook HMAC verification, SSRF discipline, signed governance, principal isolation, and insert-rate ceiling. + + + Governance token structure, the JWS verification model, and the correlation model for multi-party lifecycle tracking. + + + Security as an operating concern: credential management, rotation cadences, and incident response. + + + Principal isolation, account-scoped access, and multi-tenant separation. + + + API key enforcement, OAuth discovery, RFC 9728 audience binding, and the authentication baseline specialism. + + + +## Connecting to the test agent + +Lab exercises run against the public test agent. Use the shared token — no signup required: + +```bash +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +See the [Quickstart](/dist/docs/3.0.13/quickstart) for a walkthrough of your first call. + +## Lab exercises + +1. **Threat model walkthrough** — Map each threat (credential theft, replay, cross-tenant leakage, SSRF, spoofed identity, unauthorized governance, audit tampering) to the specific AdCP control that closes it. Explain why no single layer is sufficient alone. +2. **Idempotency lifecycle** — Submit four requests to a sandbox endpoint using the same idempotency key: (a) first call — observe success; (b) identical replay — observe `replayed: true` and confirm no side effect; (c) same key, different payload — observe conflict error; (d) after TTL lapse — observe expiry. Reason about what a missing idempotency key means for the seller's safety guarantees. +3. **Governance token verification** — Fetch a signed governance token from the sandbox governance agent. Walk the 15-step verification checklist. Tamper with the `aud` claim and observe rejection. Check the revocation list for a pre-revoked key (`test-revoked-2026`) and confirm the token is rejected before signature verification completes. Explain what each step closes. +4. **SSRF defense implementation** — Given a skeleton outbound-fetch function, add the 6-point SSRF check. Verify that a request to a cloud metadata endpoint (`169.254.169.254`) is blocked, that a redirect to a reserved IP is caught at the IP-pin step, and that error detail is suppressed in the response. +5. **Principal isolation probe** — Use two sandbox principals on the same seller. Attempt to read resources scoped to the other principal. Confirm isolation. Explain the separation model and what would break if account-scoped tokens were not enforced. +6. **Incident runbook design** — Given a credential compromise scenario (API key leaked in a public repo), design the response: which keys to rotate and in what order, how to notify counterparties, what audit events to review, and how to verify the compromise window. +7. **Defense layer diagnosis** — Given three incident descriptions (replay attack succeeded, cross-tenant data returned, governance token accepted after key revocation), identify which layer failed in each case and what specific control to harden. + +## Assessment + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Threat model fluency | 20% | Can you name an attack and the specific layer that closes it? | +| Hands-on idempotency | 20% | Can you produce all four idempotency outcomes on demand and explain their implications? | +| Governance verification | 25% | Can you walk the 15-step checklist and explain what each step prevents? | +| SSRF discipline | 15% | Can you implement the 6-point check correctly? | +| Operational design | 20% | Can you design a runbook for credential compromise, including rotation order and cross-party communication? | + +Passing threshold: 70%. diff --git a/dist/docs/3.0.13/learning/specialist/signals.mdx b/dist/docs/3.0.13/learning/specialist/signals.mdx new file mode 100644 index 0000000000..0bbe3fb43a --- /dev/null +++ b/dist/docs/3.0.13/learning/specialist/signals.mdx @@ -0,0 +1,157 @@ +--- +title: "S3: Signals and audiences" +sidebarTitle: "S3: Signals" +description: "AdCP specialist module S3: Signals and audiences. Signal discovery, activation, privacy controls, and optimization loops with sandbox data providers across six industries." +"og:title": "AdCP — S3: Signals and audiences" +--- + +# S3: Signals and audiences + + +**Members only** — Requires Practitioner credential. ~45 minutes with Addie. Combines hands-on lab and adaptive exam. + + +This specialist module tests your mastery of the signals protocol. You'll work with sandbox signal providers — automotive data, geo/mobility, retail purchase data, identity/demographics, publisher contextual signals, and CDP audiences — to discover signals, activate them, manage privacy, and design optimization loops. Addie adapts the experience to your role in the ecosystem. + +Passing earns the **AdCP specialist — Signals** credential. + +## Specialisms this track prepares you to validate + +The following `specialisms` fall under the `signals` domain. Each has its own compliance storyboard — see the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full taxonomy. + +| Specialism | Status | What it covers | +|---|---|---| +| `signal-owned` | stable | Owned signal agent exposing first-party segments | +| `signal-marketplace` | stable | Marketplace signal agent reselling third-party data | + + +**Scope of AdCP signals** + +Before investing time in training, it helps to know what the protocol covers today and where boundaries exist. + +- **In scope**: Identity-derived attributes (income tiers, life stages), behavioral signals (purchase intent, visit frequency), contextual signals (content category, sentiment), geographic audiences (trade areas, store visitors) +- **Not yet in scope**: Identity resolution and matching (linking devices to people), real-time geofencing triggers (push when someone enters a zone), measurement and attribution pipelines + +If your company works in the "not yet" areas, you can still publish the **audience segments** your data produces — the protocol covers the targeting output even when it doesn't cover the underlying infrastructure. + + +## What you'll demonstrate + +- Discover and evaluate signals from multiple provider types (data providers, retailers, publishers, CDPs) +- Activate appropriate signals for different campaign objectives +- Understand signal value types (binary, categorical, numeric) and how they affect targeting +- Manage audience activation including privacy considerations and deactivation +- Configure event tracking with `sync_event_sources` and `log_event` +- Read `pricing_options[]` from `get_signals` responses, pass the selected `pricing_option_id` through `activate_signal` and `report_usage` +- Design optimization loops using signals and delivery data +- Reason about the signals ecosystem — who provides data, who consumes it, and how authorization works + + +**Vendor pricing is consistent across protocols** — All vendor services use `pricing_options[]` on discovery responses and `pricing_option_id` in `report_usage`. Vendors may offer multiple options — volume tiers, context-specific rates, or different models per product line. Signals, creative, content standards, and property list agents all follow the same pattern. See [S2: Creative mastery](/dist/docs/3.0.13/learning/specialist/creative) for the creative pricing model. + + +## Prerequisite reading + +### Core signals tasks + + + + Signal discovery: find targetable audiences, contextual categories, and measurement data. + + + Activate signals for campaign targeting or measurement. + + + +### Supporting concepts + + + + The signals protocol: audience segments, contextual signals, measurement, and optimization. + + + Formal specification for the signals protocol. + + + How data providers publish signal catalogs via adagents.json. + + + How different company types — retailers, publishers, CDPs, identity companies — participate in signals. + + + Event sources, `log_event`, and attribution setup. + + + How signals feed into campaign optimization decisions. + + + +## Connecting to the test agent + +Lab exercises run against the public test agent. Use the shared token — no signup required: + +```bash +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +See the [Quickstart](/dist/docs/3.0.13/quickstart) for a walkthrough of your first call. + +## Lab exercises + +The sandbox training agent includes signal providers representing different ecosystem roles. You'll work with all of them: + +| Provider | Type | Signals | +|----------|------|---------| +| Trident Auto Data | Data provider | EV buyers, vehicle ownership, purchase propensity, service due, service history | +| Meridian Geo | Geo/mobility | Competitor visitors, visit frequency, trade area, commute pattern, dwell time, day-part visitation | +| ShopGrid Shopper Insights | Retailer | Category buyer, loyalty tier, basket value, new to brand, purchase frequency, brand affinity | +| Keystone Identity | Identity | Household income, life stage, cross-device reach, credit activity, household composition | +| Pinnacle News Signals | Publisher | Content category, engaged reader, subscriber tenure, sentiment, page type | +| Prism CDP | CDP | High LTV, cart abandoner, engagement score, churn risk, cross-device | + +### Exercise 1: Signal discovery + +Query the sandbox signals agent for signals matching different campaign objectives. Compare signals across provider types — what does a data provider's automotive signal look like versus a retailer's purchase signal? + +### Exercise 2: Signal activation + +Activate signals for a sandbox campaign. Observe how activation keys work and how deployment status changes. + +### Exercise 3: Audience management + +Activate and deactivate signals. Consider privacy: when does a signal need to be deactivated? How does consent affect signal availability? + +### Exercise 4: Ecosystem scenarios + +Addie will present a scenario from a specific perspective — you might be building a signal catalog for a retail media network, choosing signals for an agency's client campaign, or designing a CDP integration. Apply your protocol knowledge to the scenario. + +### Exercise 5: Build a signal catalog (provider perspective) + +The previous exercises focus on the buyer side — discovering and activating signals. This exercise shifts to the provider side. Construct an `adagents.json` signals entry for a fictional data provider of your choice (geo, retail, identity, etc.). Your catalog should include: + +- At least one signal of each value type (`binary`, `categorical`, `numeric`) +- Descriptive IDs, tags, and metadata following the [data provider guide](/dist/docs/3.0.13/signals/data-providers) +- An `authorized_agents` entry authorizing a signals agent to resell your catalog +- `restricted_attributes` declarations on signals derived from sensitive personal data (e.g., `health_data`, `racial_ethnic_origin`) +- `policy_categories` declarations on signals that carry regulatory implications (e.g., `children_directed`, `fair_housing`) + +Verify your catalog by checking that the signals appear correctly in `get_signals` results when queried by tag or description, and that governance attributes are preserved in the signal metadata. + +## Assessment + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Protocol mastery | 25% | Complete signals lifecycle (discovery → activation → targeting → deactivation) | +| Privacy compliance | 20% | Handles consent, deactivation, and data governance correctly | +| Measurement skill | 20% | Configures conversion tracking and attribution | +| Ecosystem understanding | 20% | Explains how different provider types fit into signals | +| Ecosystem scenarios | 15% | Constructs valid signal catalogs, understands both buyer and provider perspectives, reasons about activation destinations (agent vs platform) | + +Passing threshold: 70%. + +## Start this module + + + "I'd like to start the signals specialist module." + diff --git a/dist/docs/3.0.13/learning/specialist/sponsored-intelligence.mdx b/dist/docs/3.0.13/learning/specialist/sponsored-intelligence.mdx new file mode 100644 index 0000000000..99d343f1b5 --- /dev/null +++ b/dist/docs/3.0.13/learning/specialist/sponsored-intelligence.mdx @@ -0,0 +1,127 @@ +--- +title: "S5: Sponsored Intelligence" +sidebarTitle: "S5: Sponsored Intelligence" +description: "AdCP specialist module S5: Sponsored Intelligence. Monetizing AI chat — generative creative, the reversed data flow, and SI Chat Protocol for conversational brand experiences." +"og:title": "AdCP — S5: Sponsored Intelligence" +--- + +# S5: Sponsored Intelligence + + +**Members only** — Requires Practitioner credential. ~45 minutes with Addie. Combines hands-on lab and adaptive exam. + + + +**Experimental.** Sponsored Intelligence is part of AdCP 3.0 as an experimental surface (feature id `sponsored_intelligence.core`) — session lifecycle, UI components, identity/consent object shape, and capability negotiation may change between 3.x releases with at least 6 weeks' notice. Assessments in this module cover current protocol surfaces; credentials will be flagged for targeted recertification when experimental SI surfaces change — the same policy that applies to every experimental surface. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +This specialist module covers how advertising works in AI: generative creative from brand assets and catalogs, the reversed data flow where buyers push data into platforms instead of bid requests flowing out, and SI Chat Protocol where brands engage users in multi-turn conversations. Addie evaluates both your hands-on work and your understanding of when and why Sponsored Intelligence fits. + +Passing earns the **AdCP specialist — Sponsored Intelligence** credential. + +## Sponsored Intelligence as a full protocol + +In 3.0, Sponsored Intelligence was promoted from a specialism to a full protocol. An agent that supports SI declares `sponsored_intelligence` in `supported_protocols` on `get_adcp_capabilities` — not as a specialism. The compliance runner executes the SI domain baseline storyboard at `/compliance/{version}/domains/sponsored-intelligence/` plus every universal storyboard. See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog). + +## What you'll demonstrate + +- Generate creative from brand assets, catalog data, and natural language briefs +- Execute Sponsored Intelligence campaigns using the reversed data flow +- Manage SI Chat Protocol sessions for conversational brand experiences +- Explain account models for walled gardens vs agent-trusted networks +- Reason about when Sponsored Intelligence fits vs traditional approaches + +## Prerequisite reading + +### Generative creative + + + + AI-powered creative generation via `build_creative` — manifests, code output, and brand identity integration. + + + Format discovery — what ad formats a creative agent supports. + + + +### Sponsored Intelligence + + + + The reversed data flow, product spectrum, end-to-end workflow, and network aggregation pattern. + + + Product and offering catalogs — the raw material for generative creative on AI platforms. + + + How to expose inventory to AI buyer agents, including governance enforcement. + + + The `sponsored_intelligence` channel definition. + + + +### Governance integration + + + + How governance validates content standards, policy compliance, and brand safety in Sponsored Intelligence sessions. + + + Buyer-defined content rules that constrain what SI agents can say. + + + +### SI Chat Protocol + + + + Conversational brand experiences — session lifecycle, identity, and commerce handoff. + + + The formal specification for the Sponsored Intelligence protocol. + + + How AI platforms integrate SI Chat Protocol for brand experience handoffs. + + + Architecture guide for building SI brand agents. + + + +## Connecting to the test agent + +Lab exercises run against the public test agent. Use the shared token — no signup required: + +```bash +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +See the [Quickstart](/dist/docs/3.0.13/quickstart) for a walkthrough of your first call. + +## Lab exercises + +During the module, Addie will guide you through hands-on exercises: + +1. **Generative creative** — Discover formats, generate creative from brand assets and a brief, evaluate output quality +2. **Sponsored Intelligence campaign** — Push catalogs to a platform, discover products, create a media buy with optimization goals +3. **SI Chat Protocol session** — Initiate a session, exchange messages with offering integration, observe commerce handoff +4. **Strategic evaluation** — Compare Sponsored Intelligence vs traditional approaches for different scenarios + +## Assessment + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Generative creative | 25% | Builds creative from brand assets, catalogs, and briefs | +| SI mastery | 30% | Understands the reversed data flow and executes Sponsored Intelligence campaigns | +| SI Chat Protocol competence | 25% | Manages SI Chat Protocol sessions and understands conversational brand experiences | +| Strategic thinking | 20% | Reasons about when and how to use Sponsored Intelligence | + +Passing threshold: 70%. + +## Start this module + + + "I'd like to start the Sponsored Intelligence specialist module." + diff --git a/dist/docs/3.0.13/learning/test-personas.md b/dist/docs/3.0.13/learning/test-personas.md new file mode 100644 index 0000000000..c6c2e6b6f1 --- /dev/null +++ b/dist/docs/3.0.13/learning/test-personas.md @@ -0,0 +1,521 @@ +--- +title: Learner test personas +description: "AdCP documentation test personas for evaluating how content serves different user types — from legacy builders to enterprise buyers." +"og:title": "AdCP — Learner test personas" +--- + +# Learner test personas + +Seven personas for testing how well the AdCP documentation, website, and Addie serve different user types. Personas 1-3 are build-side (engineering implementation). Personas 4-6 are buy-side (strategy and adoption at different scales). Persona 7 tests the certification build project experience for non-coders. Each represents a realistic session and set of questions. + +> **Relationship to character bible**: The character bible (see `specs/character-bible.md`) defines the illustrated characters used in walkthrough panels (Alex, Sam, Jordan, Maya, etc.). Test personas here are a separate concept — they simulate real user journeys to evaluate content quality. Where a test persona maps to a walkthrough character's role, we note the connection. + +--- + +## Persona 1: Marcus Chen — Legacy AdCP builder + +### Role +Senior engineer at an agency tech team. Built a buyer agent integration against AdCP 2.5 about nine months ago. It runs in production, placing media buys for a handful of brands. + +### Background +Marcus's integration handles product discovery, creative sync, and media buy creation over MCP. He wrote it against the 2.5 schemas and hasn't touched it since launch. He saw the v3 RC announcement in Slack and knows he needs to migrate, but hasn't read the changelog yet. He's used to the protocol and doesn't need anyone to explain what MCP is or how tasks work. + +### What he already knows +- The core task flow: `get_products` -> `sync_creatives` -> `create_media_buy` -> `get_media_buy_delivery` +- How `adagents.json` works for publisher discovery +- The v2 channel enum (`display`, `video`, `audio`, `native`, `social`, `ctv`, `podcast`, `dooh`, `retail`) +- Creative IDs as string arrays on packages +- `promoted_offerings` as a creative asset type +- `fixed_rate` and `price_guidance.floor` in pricing options +- `geo_postal_codes` and `geo_metros` as flat string arrays +- `optimization_goal` as a single object on a package +- How `adcp-extension.json` works for capability discovery + +### What he doesn't know +- That `native` was removed as a channel (he has packages with `channels: ["native"]`) +- That `video` split into `olv`, `linear_tv`, and `cinema` +- That `creative_ids` became `creative_assignments` with weighting +- The new accounts model (`sync_accounts`, `list_accounts`, billing models) +- That `promoted_offerings` was replaced by first-class catalogs (`sync_catalogs`) +- That `brand_manifest` was replaced by `brand` ref (`{ domain, brand_id }`) +- That `adcp-extension.json` was replaced by `get_adcp_capabilities` +- That geo targeting now requires system specification +- That `optimization_goal` became `optimization_goals` (array, discriminated union) +- The existence of Brand Protocol, Governance, Sponsored Intelligence, or the Registry API + +### Misconceptions and blind spots +- Assumes `native` is still a valid channel. Will be confused when validation fails. +- Assumes capability discovery still uses `adcp-extension.json`. Will look for agent card extension docs that no longer exist. +- Thinks `account_id` is just a string he passes. Doesn't know about `AccountReference` objects or the explicit vs. implicit account model. +- Expects `promoted_offering` to still be a string field on media buys. +- Assumes pricing fields haven't changed names. +- Will probably search for "migration" or "upgrade" rather than "what's new." + +### Primary goal +Understand every breaking change that affects his existing integration, get a checklist of what to update, and estimate the migration effort (hours, not weeks). + +### Key questions he'd ask +1. "What broke between AdCP 2.5 and 3.0?" +2. "Is there a migration guide for v2 to v3?" +3. "What replaces the native channel?" +4. "How do I update my creative_ids to the new format?" +5. "What happened to adcp-extension.json?" +6. "Do I need to implement the accounts protocol or can I skip it?" +7. "Can I run my v2 integration alongside v3 during migration?" + +### Pages he'd likely visit +1. `/docs/reference/whats-new-in-v3` — First stop, looking for a summary of changes +2. `/docs/reference/migration/channels` — His `native` and `video` packages are broken +3. `/docs/reference/migration/pricing` — Fixing `fixed_rate` and `price_guidance.floor` +4. `/docs/reference/migration/creatives` — `creative_ids` to `creative_assignments` +5. `/docs/reference/migration/catalogs` — Replacing `promoted_offerings` +6. `/docs/reference/migration/geo-targeting` — System specification on geo fields +7. `/docs/reference/migration/optimization-goals` — Single goal to array +8. `/docs/reference/migration/brand-identity` — `brand_manifest` to `brand` ref +9. `/docs/accounts/overview` — Understanding the new accounts model +10. `/docs/protocol/get_adcp_capabilities` — Replacing `adcp-extension.json` + +### Success criteria +- He can produce a line-item list of every code change his integration needs +- He understands which changes are renames (easy) vs. structural (harder) +- He knows which new protocol domains (accounts, governance, brand protocol) are required vs. optional for his use case +- He has enough schema detail to start writing code without guessing +- Total time from landing to "I know what I need to do": under 45 minutes + +--- + +## Persona 2: Ravi Mehta — AI ad network builder + +### Role +Engineering lead at an AI ad network startup (think Kontext or Koah). His company aggregates ad inventory across multiple AI platforms — AI assistants, AI search engines, generative AI experiences — and sells it to agencies and brands through a unified interface. + +### Background +Ravi's company has partnerships with a dozen AI platforms that serve ads in their conversational and search experiences. The company's value prop is aggregation: agencies don't want to integrate with each AI platform individually, and AI platforms don't want to build their own sales teams. His ad network sits in the middle — accepting advertiser data (catalogs, budgets, brand guidelines) from agencies and distributing it to the right AI platforms. + +He's been building custom integrations with each AI platform and each agency. It doesn't scale. He heard about AdCP from a partner platform that's considering implementing it. He's evaluating whether AdCP could be the standard interface on both sides of his business: buyer agents pushing data in via AdCP on the demand side, and his network pushing that data out to AI platforms on the supply side. + +He knows ad tech deeply (ran ad ops at a mid-size SSP before this) and has built MCP servers before (his company already has an MCP-based prototype). He's technically fluent and reads protocol specs directly. + +### What he already knows +- How ad networks aggregate supply and demand +- The difference between first-party platforms (walled gardens) and networks (multi-platform) +- MCP basics — he's built an MCP server, understands tool exposure, knows how clients connect +- Traditional programmatic: OpenRTB, prebid, SSP/DSP mechanics +- His company's pain: custom integrations per platform and per agency don't scale +- That AI platforms generate creative from brand data — his network needs to pipe that data through +- Account management across multiple advertisers and platforms +- OAuth, API key management, multi-tenant architecture + +### What he doesn't know +- That AdCP has a specific `sponsored_intelligence` channel for his use case +- How `sync_catalogs` standardizes the catalog pipe he's been building custom for each platform +- How the accounts model works for a network (implicit accounts, agent-trusted model) vs. a first-party platform (explicit accounts, walled garden) +- How `adagents.json` works — he needs it for buyer agents to discover his network, and he needs to understand it from the AI platforms he connects to +- How governance policies flow through a network — do brands push content standards to his network, and does his network push them to each AI platform? +- How `optimization_goals` and `sync_event_sources` work across the network boundary — his network aggregates delivery data from multiple platforms +- Whether AdCP handles the network topology: buyer agent → ad network → AI platform, or if it assumes direct buyer-to-seller +- How Sponsored Intelligence works when the AI platform hosts the session but the brand was introduced through his network + +### Misconceptions and blind spots +- **Assumes AdCP is buyer-to-seller only.** His business is a network in the middle. He's worried the protocol doesn't account for intermediaries — that he'd have to pretend to be either a buyer or a seller. +- **Thinks accounts are simple.** His network manages accounts on behalf of agencies who manage accounts on behalf of brands. He's used to this complexity but doesn't know how AdCP's account model handles multi-level delegation. +- **Assumes he needs to build the catalog pipe himself.** He's been building custom catalog sync integrations with each AI platform. Doesn't realize `sync_catalogs` is a standard that could work on both sides of his business. +- **Conflates his network's products with the underlying platform's products.** He sells "sponsored responses across AI assistants" as a single product, but each underlying AI platform has its own product IDs, pricing, and formats. He needs to understand how to model aggregated products in AdCP. +- **Thinks governance is pass-through.** Assumes he just forwards brand safety rules from buyer to platform. Doesn't know about governance policies as structured objects that his network could enforce at the routing layer before forwarding to platforms. + +### Primary goal +Determine whether AdCP works for a network topology (buyer → network → platform). If yes, understand how to model his business: how does his network appear to buyers (as a seller agent), how does it interact with AI platforms (as a buyer or operator), and how do catalogs, accounts, and governance flow through the network layer. + +### Key questions he'd ask +1. "Does AdCP support a network in the middle, or is it strictly buyer-to-seller?" +2. "How do I model my ad network's products when they aggregate across multiple AI platforms?" +3. "How do accounts work for a network? Agencies have accounts with me, I have accounts with each AI platform." +4. "Can I use `sync_catalogs` on both sides — accept catalogs from agencies and forward them to AI platforms?" +5. "How do governance policies and content standards flow through a network?" +6. "How does delivery reporting work when I'm aggregating across multiple platforms?" +7. "What does my `adagents.json` look like? I represent multiple publisher properties that aren't mine." +8. "How does SI work when I'm the intermediary — the brand was introduced through my network but the session runs on the AI platform?" +9. "What's the account model — `require_operator_auth: false` since I'm agent-trusted?" +10. "Are there other networks using AdCP or am I the first?" + +### Pages he'd likely visit +1. `/docs/sponsored-intelligence/overview` — Core page, looking for network-specific guidance +2. `/docs/building/implementation/seller-integration` — How to appear as a seller to buyer agents +3. `/docs/accounts/overview` — Network account model (agent-trusted, implicit accounts) +4. `/docs/building/integration/accounts-and-agents` — Multi-level account delegation +5. `/docs/creative/catalogs` — Catalog sync mechanics for pass-through +6. `/docs/governance/overview` — How governance flows through intermediaries +7. `/docs/media-buy/product-discovery/media-products` — Modeling aggregated products +8. `/docs/media-buy/advanced-topics/accounts-and-security` — `adagents.json` for networks +9. `/docs/protocol/get_adcp_capabilities` — What capabilities a network declares +10. `/docs/sponsored-intelligence/overview` — SI through a network intermediary +11. `/docs/building/integration/mcp-guide` — MCP server patterns (he's familiar but wants AdCP-specific guidance) +12. `/docs/reference/media-channel-taxonomy` — `sponsored_intelligence` channel definition + +### Success criteria +- He understands how his network appears to buyers (seller agent with implicit accounts) and how it interacts with AI platforms (operator with explicit accounts on each platform) +- He can model his aggregated products — products that span multiple underlying AI platforms with different pricing +- He knows how catalogs flow through: buyer → his network → AI platform, using `sync_catalogs` on both legs +- He understands the governance flow — content standards from brands can be enforced at his network layer and forwarded to platforms +- He can explain the account chain: brand → agency → his network → AI platform, and how AdCP models each relationship +- He has enough to architect the AdCP integration on both sides of his business +- Total time from landing to "I can write an architecture doc": under 90 minutes + +--- + +## Persona 3: Tomoko Hayashi — AI platform ad infrastructure lead + +### Role +Senior product manager on the ads team at a major AI assistant platform. Think ChatGPT-scale: hundreds of millions of users, strong commercial intent signals, and leadership has decided to build an ad-supported tier. She's responsible for the demand-side architecture — how advertiser data and budgets flow into the platform. + +### Background +Tomoko's team has already built the serving infrastructure — the platform can render sponsored responses, inject contextual recommendations, and handle brand experience sessions. The LLM is good at generating relevant, on-brand content when it has the right inputs. The hard problem now is plumbing: how do hundreds of advertisers get their product catalogs, conversion events, brand guidelines, and content standards into the platform at scale? And how do agencies and their AI agents discover the platform's ad products and execute buys programmatically? + +She's evaluated two approaches: (1) build a proprietary API and let each buyer integrate one-by-one, or (2) adopt an open standard so any compliant buyer agent can plug in. She's looking at AdCP for option 2. She's also been pitched by traditional SSPs (prebid, GAM) and is skeptical — the bid request model sends thin signals out to a remote decision-maker that doesn't have the conversation context. Her platform has the context. She wants the data to come to her. + +### What she already knows +- Her platform's LLM capabilities — what it can generate when given the right brand data and context +- How ad serving works internally on her platform (sponsored response ranking, context matching, session management) +- The scale problem: onboarding advertisers one-by-one through a proprietary API doesn't scale +- That traditional programmatic (bid requests out, ads back) is a poor fit because the remote bidder doesn't have conversation context +- Basic ad tech: CPM, CPC, cost-per-engagement, fill rate, frequency capping +- That her platform needs advertiser product data to generate good ads — they've been scraping it manually for early tests +- OAuth, API design, webhook patterns — she's technical enough to evaluate protocol specs + +### What she doesn't know +- That AdCP has a specific `sponsored_intelligence` channel designed for her platform's use case +- How `sync_catalogs` works as the standard pipe for getting advertiser product data in at scale +- How `sync_event_sources` lets advertisers push conversion signals in so the platform can optimize on real outcomes +- How governance policies let brands push content standards in — suitability rules the platform enforces at generation time +- How `brand.json` provides brand identity (voice, visual guidelines, positioning) that improves generated creative quality +- That `optimization_goals` on media buys tell the platform what success looks like for each campaign +- What MCP is and how it differs from building a REST API (she's been assuming she'd build REST) +- How `adagents.json` works for buyer agents to discover her platform +- How accounts work — whether she should require OAuth per advertiser or let buyer agents declare brands +- That Sponsored Intelligence is a separate protocol for multi-turn brand experiences, not just "fancy sponsored responses" + +### Misconceptions and blind spots +- **Thinks the choice is proprietary API vs. SSP.** Doesn't yet see that AdCP is a third option: an open standard designed for her exact use case — receiving data in, not sending bid requests out. +- **Assumes she needs to build a REST API.** Doesn't know MCP exists as a transport that AI agents already speak natively. Her platform's buyer agents are LLMs — they already know how to call MCP tools. +- **Underestimates the catalog problem.** Her team has been manually onboarding product feeds from early advertisers. She knows this doesn't scale but doesn't realize `sync_catalogs` solves it as a standard. +- **Thinks of brand safety as a blocklist.** Doesn't know about governance policies that let brands push suitability rules into the platform — rules the LLM enforces during creative generation, not as post-hoc filtering. +- **Conflates sponsored responses with SI.** Thinks brand experience handoffs are just richer sponsored responses. Doesn't understand that SI is a separate session lifecycle where the brand's own agent takes over the conversation. +- **Assumes conversion tracking requires her own pixel/SDK.** Doesn't know `sync_event_sources` lets advertisers push their existing conversion data in so the platform can optimize without building its own measurement stack. +- **Hasn't thought about the "why not just do programmatic?" question from the other side.** She needs to articulate to her leadership why AdCP is better for her platform than integrating with an SSP — the answer is that programmatic sends thin signals out while AdCP brings rich data in, and her LLM can use that data to make better ad decisions than any remote bidder could. + +### Primary goal +Decide whether to adopt AdCP as the standard interface for her platform's demand-side plumbing. If yes, understand what she needs to build (MCP server, account model, catalog ingestion, product schema) and how it compares to the alternative (proprietary REST API or SSP integration). Write a technical design doc her engineering team can act on. + +### Key questions she'd ask +1. "How does AdCP get advertiser product data into my platform? Is there a standard for catalog sync?" +2. "Can advertisers push conversion events in so we can optimize on real outcomes instead of proxy metrics?" +3. "How do brand safety and content standards work? Can brands push suitability rules that my LLM enforces during creative generation?" +4. "Why would I adopt an open standard instead of building my own API? What do I get?" +5. "What's MCP and why would I build an MCP server instead of a REST API?" +6. "How do buyer agents discover my platform and its ad products?" +7. "What's the account model? Do I need OAuth per advertiser or is there a simpler path?" +8. "What's the difference between sponsored responses and Sponsored Intelligence?" +9. "Why is this better than integrating with a traditional SSP? How do I explain this to my leadership?" +10. "Who else is doing this? Are there reference implementations?" + +### Pages she'd likely visit +1. `/docs/intro` — Starting point, looking for AI-specific framing +2. `/docs/sponsored-intelligence/overview` — The core page for her use case — expects to find the reversed data flow argument, catalog sync, governance, and product modeling +3. `/docs/creative/catalogs` — Deep dive on catalog sync — this is her biggest operational pain point +4. `/docs/building/implementation/seller-integration` — What she'd need to build as a seller agent +5. `/docs/governance/overview` — How content standards work as an "oracle" the platform queries/receives +6. `/docs/media-buy/media-buys/optimization-reporting` — How optimization goals and conversion events work +7. `/docs/accounts/overview` — Understanding account models (walled garden vs. agent-trusted) +8. `/docs/building/integration/mcp-guide` — Why MCP instead of REST, what an MCP server looks like +9. `/docs/sponsored-intelligence/overview` — Understanding the SI session lifecycle vs. sponsored responses +10. `/docs/media-buy/product-discovery/media-products` — How to model her inventory as products +11. `/docs/protocol/get_adcp_capabilities` — What capabilities she'd declare +12. `/docs/building/understanding/adcp-vs-openrtb` — Ammunition for the "why not SSP?" conversation with leadership + +### Success criteria +- She can articulate to her leadership why AdCP is better for her platform than SSP integration — the reversed data flow argument: "We have the conversation context. AdCP brings us the brand data, conversion signals, and suitability rules so our LLM can make great ad decisions locally. SSPs would make us send thin bid requests to a remote system that doesn't have our context." +- She understands the data pipes: `sync_catalogs` for product data, `sync_event_sources` for conversion signals, governance policies for content standards, `brand.json` for brand identity, `optimization_goals` for success definitions +- She can describe the account model she'd implement and why (walled garden with OAuth, since she's a first-party platform) +- She knows the difference between implementing sponsored responses (product-level, catalog-driven) and SI (session-level, brand agent handoff) +- She can spec the MCP server her team would build: which tasks to implement, which capabilities to declare, how catalog ingestion maps to her existing infrastructure +- She has a clear comparison: AdCP (open standard, data flows in, any buyer agent can plug in) vs. proprietary API (custom per buyer, same data flow but no ecosystem) vs. SSP (wrong direction — sends signals out) +- Total time from landing to "I can write a technical design doc": under 90 minutes + +--- + +## Persona 4: Daniela Reyes — Agency trading desk exec + +### Role +VP of Programmatic at a mid-size independent agency. Her team manages $200M+ in annual digital spend across 30+ brands. She reports to the CEO and sits on the agency's AI transformation committee. + +### Background +Daniela came up through trading desks — she ran programmatic operations at a holding company before joining this independent shop. She knows DSPs, SSPs, OpenRTB, and prebid inside and out. Her team is 15 traders and 3 engineers who maintain custom bidding algorithms and reporting dashboards. + +She's been hearing about "AI media" from clients and at industry conferences. Two of her largest clients (a CPG brand and a financial services company) have asked her team to "figure out how to buy ads on ChatGPT and Perplexity." She tried to set up direct deals with those platforms but each requires a different API, different creative specs, different reporting formats. She's looking for a standard way to buy across AI surfaces the same way her team buys across traditional programmatic. + +She's not an engineer — she doesn't write code. But she evaluates technology, makes buy/build decisions, and briefs her engineering team on what to implement. She reads docs at the conceptual level, skims schemas for shape, and focuses on workflow, economics, and competitive advantage. + +### What she already knows +- Programmatic advertising deeply: DSPs, SSPs, ad exchanges, OpenRTB bid/response flow +- Campaign management: flights, budgets, pacing, optimization, frequency capping +- Creative trafficking: tag management, VAST/VPAID, DCO +- Measurement: viewability, brand safety vendors (IAS, DV), attribution, MMM +- Agency economics: margins, managed service vs. self-serve, platform fees +- That AI platforms are a new media channel and clients are asking for it +- That the current approach (direct deals per platform) doesn't scale + +### What she doesn't know +- That AdCP exists as a standard for AI media buying +- What "reversed data flow" means and why it matters for her agency +- That her team could use a single buyer agent to buy across multiple AI platforms +- How catalogs replace creative tags — instead of trafficking assets, you push product data +- That AI platforms generate the creative from her brand's data +- How accounts work across platforms — does she need separate logins everywhere? +- What governance looks like in AI media — are IAS and DV relevant, or is it different? +- That optimization goals replace the DSP optimization algorithms she's used to +- What MCP is and why it matters (she thinks in terms of APIs and dashboards) +- That Sponsored Intelligence exists as a deeper brand engagement format +- How pricing works — is it auction-based like RTB, or fixed, or something else? + +### Misconceptions and blind spots +- **Maps everything to programmatic.** She'll try to understand AdCP through the lens of DSPs and SSPs. "So the buyer agent is like a DSP?" "Is adagents.json like ads.txt?" Some of these analogies help, some mislead. +- **Expects a UI.** She's used to DSP dashboards. The idea that her buyer agent does everything programmatically, without a campaign management UI, is unfamiliar. She'll want to know where the dashboard is. +- **Thinks creative is her job.** In traditional programmatic, the agency builds creative and traffics it. In AI media, the platform generates creative from brand data. This is a big mental shift. +- **Assumes brand safety means the same vendors.** She'll look for IAS/DV integration. The idea that governance is built into the protocol (content standards enforced at generation time) rather than bolted on as third-party verification is new. +- **Underestimates the catalog workflow.** She thinks of product feeds as a retail media thing. Doesn't realize that ALL AI media buying starts with pushing catalogs and brand data into platforms. +- **Thinks of AI media as "just another channel."** She'll want to add it to her existing programmatic stack as a new line item. The paradigm shift — data flows in, not bid requests out — requires rethinking the workflow, not just adding a channel. + +### Primary goal +Understand whether AdCP is the right standard for her agency to adopt for AI media buying. Build a business case for her CEO and a technical brief for her engineering team. Figure out the competitive advantage: if she adopts this before other agencies, does she win? + +### Key questions she'd ask +1. "How is buying ads on AI platforms different from buying on a DSP?" +2. "Is there a standard way to buy across ChatGPT, Perplexity, and other AI platforms?" +3. "What does a campaign workflow look like? Where does my team fit?" +4. "Do I still need to build creative, or does the platform handle that?" +5. "How does brand safety work? Can I use IAS/DV?" +6. "What's the pricing model? Is it auction-based?" +7. "How do I report on this? Can I get it into my existing dashboards?" +8. "What do I need my engineering team to build?" +9. "How do accounts and billing work across multiple platforms?" +10. "Is anyone else doing this? What's the competitive landscape?" + +### Pages she'd likely visit +1. `/` — Homepage, looking for "what is this and why should I care" +2. `/docs/intro` — Orientation, hoping for a clear value prop +3. `/docs/building/understanding/adcp-vs-openrtb` — Directly answers her "how is this different" question +4. `/docs/sponsored-intelligence/overview` — The core guide for her use case (she's the buyer) +5. `/docs/sponsored-intelligence/workflow` — Wants to see what the workflow looks like, even if she won't code it +6. `/docs/building/implementation/seller-integration` — Might read this to understand the other side +7. `/docs/governance/overview` — How brand safety works in this world +8. `/docs/creative/catalogs` — Understanding the catalog workflow +9. `/docs/accounts/overview` — How multi-platform billing works +10. `/docs/reference/media-channel-taxonomy` — Looking for `sponsored_intelligence` in the channel list + +### Success criteria +- She can explain to her CEO why AI media is different from adding a new DSP, and why adopting a standard matters +- She can brief her engineering team on what to build: "We need a buyer agent that speaks AdCP. Here's the workflow: push catalogs, discover products, create media buys, pull delivery reports." +- She understands the creative paradigm shift: agencies provide brand data and catalogs, platforms generate creative +- She knows the governance model: content standards are protocol-level, generation-time enforcement, not third-party bolt-ons +- She can estimate the engineering investment and timeline for her 3-person eng team +- She sees the competitive advantage: first agency to have a working buyer agent can serve client demand for AI media faster than agencies doing direct deals +- Total time from landing to "I can present this to my CEO": under 60 minutes + +--- + +## Persona 5: James Okafor — Brand media transformation leader + +### Role +Global head of media at a Fortune 500 consumer electronics brand. Reports to the CMO. Manages a $500M annual media budget across three agency partners and a growing in-house team. He chairs the brand's "Media of the Future" initiative. + +### Background +James has been in brand-side media for 15 years, moving from media planner to running the entire function. He's navigated every major shift: programmatic, social, retail media, CTV. He knows the agency relationship well — he briefs agencies on strategy and KPIs, they execute campaigns and report back. His in-house team handles retail media (Amazon, Walmart) directly and is experimenting with bringing more programmatic in-house. + +His CMO has flagged AI media as the next priority. Consumers are increasingly using AI assistants to research and buy products. His brand's products are showing up in AI-generated responses — sometimes accurately, sometimes not. He wants to move from "hope the AI mentions us correctly" to "actively reach consumers in AI experiences with accurate brand messaging." + +He's not technical. He thinks in terms of media strategy, brand equity, consumer journeys, and ROAS. He evaluates technology through the lens of business outcomes, agency relationships, and organizational readiness. + +### What he already knows +- Media strategy and planning at scale: reach, frequency, GRPs, cross-channel allocation +- Agency management: briefing, negotiation, performance evaluation, fee structures +- Retail media: he's been through the learning curve of Amazon Ads, Walmart Connect, Instacart Ads +- Brand safety as a business risk: he's had brand safety incidents and knows the cost +- That consumers are using AI assistants to research purchases in his category +- That his competitors are starting to experiment with AI advertising +- The in-house vs. agency dynamic: some capabilities are better owned, others are better outsourced + +### What he doesn't know +- What AdCP is or that a standard exists for AI advertising +- How AI advertising actually works — he's seen demos but doesn't understand the mechanics +- That the creative is generated by the AI platform from his brand's data (catalogs, brand guidelines) +- That he can push his brand's content standards into AI platforms to control how his brand appears +- That "catalog quality drives ad quality" — his product data is the creative input +- How governance works differently in AI media (generation-time enforcement vs. post-hoc verification) +- That Sponsored Intelligence lets his brand have multi-turn conversations with consumers +- How pricing works on AI platforms — it's not the same as programmatic auctions +- What his agencies need from him to execute AI media campaigns (catalogs, brand.json, content standards) +- That the organizational model for AI media looks more like retail media (data + content) than traditional programmatic (creative + targeting) + +### Misconceptions and blind spots +- **Thinks AI advertising is banner ads in AI apps.** Imagines display ads next to ChatGPT's responses. Doesn't realize the AI generates the ad from his brand data — the "ad" is a sponsored response that looks and feels native to the AI experience. +- **Assumes his agencies already know how to do this.** They don't. AI media is new enough that his agencies are figuring it out too. He needs to understand enough to evaluate their proposals and push them in the right direction. +- **Thinks brand safety means the same thing.** In traditional media, brand safety = avoiding bad content adjacency. In AI media, brand safety = controlling how the AI talks about your brand. Different problem, different solution. +- **Underestimates the data requirement.** His team manages product feeds for retail media and a DAM for creative assets. He doesn't realize that AI media requires even richer brand data — product catalogs, brand voice guidelines, content standards — and that the quality of this data directly determines ad quality. +- **Assumes it's an agency problem.** He'll want to brief his agency and have them figure it out. But AI media requires brand-side inputs (catalogs, brand identity, content standards) that the agency can't generate. He needs to own the data pipeline. +- **Thinks Sponsored Intelligence is fancy retargeting.** Needs to understand that SI is a new engagement model — the consumer has a conversation with his brand inside an AI assistant. + +### Primary goal +Understand what AI advertising is, whether his brand should invest, and what organizational changes are needed. Build the business case for the CMO. Brief his agencies on what to do differently. Identify what his in-house team needs to own vs. delegate. + +### Key questions he'd ask +1. "What is AI advertising and how is it different from what we do today?" +2. "How do consumers experience ads in AI assistants?" +3. "Can I control how the AI talks about my brand?" +4. "What data does my team need to provide?" +5. "How does brand safety work when the AI generates the creative?" +6. "What should I ask my agencies to do?" +7. "How do I measure this? Can I get ROAS?" +8. "What does pricing look like compared to programmatic?" +9. "Is there a way to test this without a big investment?" +10. "What are my competitors doing?" + +### Pages he'd likely visit +1. `/` — Homepage, looking for the big picture +2. `/docs/intro` — "Explain this to me like I'm a CMO" +3. `/docs/sponsored-intelligence/overview` — Core guide, but may bounce if it's too technical +4. `/docs/building/understanding/adcp-vs-openrtb` — Wants the comparison to what he knows +5. `/docs/creative/catalogs` — Understanding what data his team needs to provide +6. `/docs/governance/overview` — Brand safety and content standards (high priority for him) +7. `/docs/governance/content-standards/overview` — Deep dive on brand control +8. `/docs/sponsored-intelligence/overview` — Understanding the conversational engagement model +9. `/docs/creative/brand-json` — What brand identity data he needs to create +10. `/docs/learning/basics/intro` — Might try the certification to learn structured content + +### Success criteria +- He can explain to his CMO what AI advertising is and why it's different from programmatic — not just "ads in AI apps" but "AI generates ads from our brand data" +- He understands the organizational implications: his team needs to own brand data quality (catalogs, brand identity, content standards) the same way they own retail media product feeds +- He can brief his agencies: "We need you to build or adopt a buyer agent that speaks AdCP. Here's what we'll provide: product catalogs, brand.json, content standards. Here's what we expect: AI media campaigns across the major AI platforms with delivery reporting." +- He knows the governance story: "We push our content standards into AI platforms. They enforce them at generation time. No more hoping the AI says the right thing about our brand." +- He sees Sponsored Intelligence as a new consumer engagement channel, not just ads +- He can articulate the competitive risk: "If we don't invest in AI media data quality now, our competitors will have better-performing ads in AI experiences because their brand data is richer" +- He has a phased plan: (1) audit brand data readiness, (2) pilot with one agency on one AI platform, (3) scale through AdCP standard +- Total time from landing to "I can present this to the CMO": under 45 minutes + +--- + +## Persona 6: Priya Sharma — SMB e-commerce founder + +### Role +Founder and sole operator of a direct-to-consumer skincare brand. Runs on Shopify. Does $2M annual revenue. Handles marketing herself with occasional freelance help. Has a product catalog of 40 SKUs. + +### Background +Priya built her brand on Instagram and Google Shopping. She manages her own Meta Ads, Google Ads, and recently started on Amazon. Each platform has its own ad manager, its own creative requirements, its own pixel/conversion setup. It's manageable at three platforms, but she's hearing from her customers that they found her products through ChatGPT and Perplexity recommendations — and she has no presence there. No ads, no brand profile, no control over how her products are described. + +She looked into advertising on ChatGPT and found it requires a direct sales relationship. Perplexity has a different program. Every AI platform is different. She doesn't have an agency, she doesn't have engineers, and she doesn't have time to set up and manage five more platforms individually. + +She's technically capable — she can configure Shopify apps, set up Meta pixels, use Zapier — but she doesn't write code. She thinks in terms of "connect my store to this platform" and "set a budget and let it run." + +### What she already knows +- How to run ads on Meta, Google, and Amazon — campaign setup, budgets, targeting, creative +- That Shopify has app integrations that connect her store to ad platforms +- Product feed management — she maintains her Google Merchant Center feed +- Basic measurement: ROAS, CPA, attribution windows +- That her products are appearing in AI assistant responses, sometimes with wrong prices or discontinued items +- That she can't currently control or improve how AI platforms represent her brand + +### What she doesn't know +- That AdCP exists or what "agentic advertising" means +- That there's a standard way to connect to multiple AI platforms at once +- That her existing Shopify product feed is basically a catalog she could push to AI platforms +- That AI platforms generate ads from her product data — not from creative she uploads +- That brand.json could establish her brand identity across all AI platforms +- That content standards could prevent AI platforms from making claims about her products she hasn't approved +- That she'd likely work through a partner (ad network, Shopify app) rather than implementing AdCP directly +- What MCP or A2A are — she thinks in terms of apps and integrations, not protocols + +### Misconceptions and blind spots +- **Thinks advertising on AI platforms means a dashboard.** She expects something like Meta Ads Manager — upload creative, set targeting, set budget, launch. The idea that AI platforms generate the ad from her data is unfamiliar. +- **Assumes she needs to do it platform by platform.** Just like she has separate accounts on Meta, Google, and Amazon, she assumes she'd need separate accounts on ChatGPT, Perplexity, Claude, Gemini, etc. +- **Doesn't realize her product feed is already most of what she needs.** Her Google Merchant Center feed has titles, descriptions, prices, images, availability. That's a product catalog. She just needs to push it to AI platforms through a standard pipe. +- **Thinks she can't afford this.** Associates "AI advertising" with enterprise budgets. Doesn't know that AI ad networks could let her start with $500/month across multiple platforms. +- **Underestimates the brand control problem.** Her products are already being discussed in AI conversations — sometimes incorrectly. She hasn't connected this to the opportunity: if she pushes accurate product data AND brand guidelines, the AI has the right information instead of guessing. + +### Primary goal +Figure out if she can advertise on AI platforms without hiring an agency or an engineer. Understand what she'd need to provide (her product data, her brand info) and who would help her do it (a Shopify app, an ad network, a partner). Start small and see if it works. + +### Key questions she'd ask +1. "Can I advertise on ChatGPT and Perplexity? How?" +2. "Do I need an agency or can I do this myself?" +3. "Can I just connect my Shopify store?" +4. "How much does it cost to get started?" +5. "Do I need to make new creative or does the AI do that?" +6. "How do I make sure the AI gets my products right — prices, descriptions, availability?" +7. "Can I control what the AI says about my brand?" +8. "How do I know if it's working? Can I see ROAS?" +9. "Is there a Shopify app for this?" +10. "What's the difference between this and just doing Google Ads?" + +### Pages she'd likely visit +1. `/` — Homepage, looking for plain-language explanation +2. `/docs/intro` — Might bounce if too technical +3. `/docs/sponsored-intelligence/overview` — If the buyer section catches her, she'll read it +4. `/docs/creative/catalogs` — Wants to know if her Shopify feed works +5. `/docs/brand-protocol/brand-json` — Wants to control her brand representation +6. `/docs/governance/overview` — Wants to prevent AI from making wrong claims about her products +7. `/docs/learning/overview` — Might try the basics to understand the landscape + +### Success criteria +- She understands that her existing product feed is the main ingredient she needs +- She knows she'd work through a partner (ad network, Shopify app) that handles the protocol plumbing +- She sees the value: one integration (through a partner) reaches all AI platforms vs. setting up each one individually +- She understands the brand control story: push accurate data and guidelines so AI platforms represent her brand correctly +- She's not scared off by protocol jargon — the content meets her where she is +- She has a clear next step: find an AdCP-connected partner that works with Shopify +- Total time from landing to "I know what to do next": under 20 minutes + +--- + +## Persona 7: Lisa Tran — Non-coder doing a build project + +### Role +VP of Digital at a mid-market retail brand. Manages the brand's digital media strategy and vendor relationships. Comfortable with AI coding assistants (uses Cursor daily for internal tooling prototypes) but has never written TypeScript or JavaScript by hand. + +### Background +Lisa completed the C-track certification modules (C1-C3) and is starting the C4 build project. She's used Cursor to build small internal tools — Slack bots, spreadsheet automations, simple dashboards — by describing what she wants and iterating on the output. She's never read a stack trace, doesn't know what `npm` is, and thinks of "running code" as "it works when I press play in Cursor." + +She passed C1-C3 because the material is conceptual — buying workflows, product discovery, campaign strategy. C4 asks her to build a working buyer agent. She understands what the agent should do (discover products, create media buys, sync creatives) but the gap between "I can describe it" and "it runs" is where she'll struggle. + +### What she already knows +- AdCP buying concepts from C1-C3: product discovery, media buys, creative sync, targeting, optimization goals +- How to describe what she wants to an AI coding assistant in plain language +- The iterate-with-AI workflow: describe → generate → test → describe again +- Her brand's media buying needs — she has real context for the scenario +- That `@cptestagent` is the sandbox seller she'll test against + +### What she doesn't know +- What a "running MCP server" means or how to verify one is running +- How to read error messages — she'll see `TypeError: Cannot read properties of undefined` and not know what to do +- That `npm install` or `pip install` might be needed before the code runs +- How to "paste JSON responses back" — she may not know what JSON looks like vs. other terminal output +- That her AI coding assistant needs the adcp client library specified in the prompt +- How to connect her local agent to Addie for the validation phase + +### Where she'll get stuck +- **First build attempt fails.** Her AI coding assistant produces code that doesn't run. She sees an error in the terminal but doesn't know which part is the error vs. normal output. +- **Doesn't know how to iterate.** She knows how to iterate in Cursor for simple tools, but a multi-file TypeScript project with dependencies is different from a single-file Slack bot. +- **Confuses specification problems with code problems.** If the agent doesn't handle error cases, is that because her specification was incomplete or because the AI coding assistant made a mistake? She can't tell. +- **Validation phase is confusing.** "Run this MCP tool call against your local agent" — she doesn't know what that means mechanically. + +### What she needs from Sage +- **Phase 1 (Specify)**: She'll do well here. She can describe a buying workflow in AdCP terms. Sage should confirm her specification is complete enough for the coding assistant. +- **Phase 2 (Build)**: When the build fails, she needs Sage to teach the debug loop — not debug for her. "Copy that error message, paste it back to Cursor, and say 'this error appeared when I tried to run it.'" If it fails again, "Tell Cursor what you're trying to build and that it should fix the error." She needs to learn that 2-3 cycles is normal, not a sign she's failing. +- **Phase 3 (Validate)**: She needs clear, mechanical instructions. Not "run get_products against your agent" but guidance on exactly how to invoke the tool and what output to copy back. +- **Phase 4 (Explain)**: She'll do well here — she understands the concepts. +- **Phase 5 (Extend)**: Same pattern as Phase 2 — specify the change, iterate with the coding assistant, bring back results. + +### Success criteria +- She completes the build project without anyone writing code for her +- She learns the debug loop: error → paste to assistant → iterate +- She's not blocked for more than 5 minutes on any mechanical step +- The experience feels like coaching, not like failing at engineering +- She'd recommend the certification to a peer who also doesn't code diff --git a/dist/docs/3.0.13/learning/tracks/buyer.mdx b/dist/docs/3.0.13/learning/tracks/buyer.mdx new file mode 100644 index 0000000000..1751c5ffef --- /dev/null +++ b/dist/docs/3.0.13/learning/tracks/buyer.mdx @@ -0,0 +1,263 @@ +--- +title: "Buyer / brand track" +sidebarTitle: Buyer track +description: "AdCP buyer track (C1-C4): multi-agent buying orchestration, brand identity protocols, creative workflows, sponsored intelligence, and a buyer agent build project." +"og:title": "AdCP — Buyer / brand track" +--- + +# Buyer / brand track (C1–C4) + + +**Members only** — Requires Basics credential (A1–A3). Four modules, ~105 minutes total. + + +This track teaches you the demand side of AdCP. You'll learn how buyer agents orchestrate across multiple sellers, how brand identity and compliance protocols work, and how creative workflows and Sponsored Intelligence fit together. The track culminates in a build project where you create a working buyer agent. + +Completing this track (plus A1–A3) earns the **AdCP practitioner** credential. + +--- + +## C1: Multi-agent buying and media planning + +**~20 min** | Prerequisite: A3 + +How buyer agents orchestrate across multiple sales agents simultaneously: discovery, portfolio allocation, proposals, and measuring reach across publishers. + +### Reading list + + + + The media buy protocol: how agents discover, negotiate, and execute advertising campaigns. + + + Product discovery from multiple sellers — the first step in any buying workflow. + + + Campaign creation: manual mode, proposal mode, validation, and approval lifecycle. + + + Architecture patterns for agents that coordinate across multiple sellers. + + + `pricing_options[]` on products, buyer selects by `pricing_option_id`: CPM, vCPM, CPP, CPA, flat rate, time. + + + `performance_standards`, `measurement_terms`, and `cancellation_policy` — negotiable on guaranteed buys. + + + Targeting options, audience overlays, and geo-targeting. + + + Declare `adcp_major_version` on requests; sellers respond with `VERSION_UNSUPPORTED` when incompatible. + + + Error taxonomy: transient, correctable, terminal. Includes `GOVERNANCE_DENIED`, `TERMS_REJECTED`, `VERSION_UNSUPPORTED`. + + + How your campaigns execute at impression time: context match, identity match, and cross-publisher frequency capping. + + + Endpoint integration: responding to Context Match and Identity Match requests, structuring offers, provider registration. + + + Operational monitoring: lifecycle state, creative approvals, valid actions, delivery snapshots. + + + +### Key concepts + +- **Multi-agent buying** — query multiple sellers in parallel, compare, allocate, execute +- **Orchestration pattern** — discover, evaluate, allocate, execute, monitor +- **Order lifecycle** — `pending_creatives` → `pending_start` → `active`; check `valid_actions` from `get_media_buys` before acting. S1 covers the full state machine and recovery +- **Version negotiation** — declare `adcp_major_version` on every request; handle `VERSION_UNSUPPORTED` by selecting a compatible seller or downgrading the payload +- **Pricing selection** — products return `pricing_options[]`; buyer selects one via `pricing_option_id` in `create_media_buy` +- **Negotiated accountability** — for guaranteed buys, propose `measurement_terms` / `performance_standards`; seller accepts, adjusts, or returns `TERMS_REJECTED`. S1 covers the recovery patterns +- **Account required on updates** — `update_media_buy` takes `account` + `media_buy_id`; omitting `account` is a protocol error +- **Audience targeting** — `sync_audiences` for custom segments +- **Impression-time execution** — campaigns activate through TMP at serve time; frequency caps, audience eligibility, and brand suitability are enforced via Identity Match without exposing user data to buyers alongside content context +- **Account setup** — `sync_accounts` to establish billing before buying + + + "I'd like to start certification module C1." + + +--- + +## C2: Brand identity, compliance, and safety + +**~20 min** | Prerequisite: C1 + +The Brand Protocol (`brand.json`), content standards, and how brand agents enforce guidelines across automated buying. + +### Reading list + + + + Brand identity claims, brand.json discovery, brand hierarchy, and brand agents. + + + The brand.json format: identity, logos, colors, guidelines, and agent declarations. + + + How advertisers license talent rights through the brand protocol — pricing, scopes, and what you get. + + + Search for licensable talent rights — pricing, availability, and exclusion filtering. + + + License talent rights: generation credentials, rights constraints, revocation, and approval workflows. + + + Extend, adjust, or pause an existing rights grant. + + + How rights holders monetize talent through the brand protocol. + + + How content standards define what's appropriate, how calibration works, and local execution. + + + Testing whether content meets brand standards before placement. + + + How compliance is enforced during media buy execution. + + + Creative quality and compliance governance. + + + Tie campaigns to media plans: budget authority, multi-party validation, and always-on compliance. + + + Community-maintained compliance policies (COPPA, GDPR, HFSS) that brands reference by ID. + + + +### Key concepts + +- **Brand identity protocol** — `brand.json` at `/.well-known/brand.json` declares brand identity +- **Content standards** — automated compliance checking via MCP-based brand agents +- **The Oracle model** — using AI to evaluate brand safety at scale +- **Supply chain preferences** — suitability, safety, and sustainability requirements +- **Campaign governance** — campaign plans define authorized parameters; `check_governance` validates every transaction before execution, returns a `governance_context` token you attach to the buy envelope, and uses `purchase_type` to scope which rules apply (`media_buy`, `rights_license`, `signal_activation`, `creative_services`) +- **Governance denial recovery** — `GOVERNANCE_DENIED` is a correctable buyer-side decision, not a transport error: fix targeting, creative, or plan reference and retry. S4 goes deeper on the correlation model +- **Policy registry** — shared compliance policies (regulations and standards) referenced by ID, not written per-brand +- **Governance adoption** — governance agents support incremental adoption (audit → advisory → enforce) as an internal configuration, not a protocol field +- **Policy categories** — regulatory regimes (`children_directed`, `fair_housing`, `fair_lending`) that plans declare and governance agents enforce +- **Restricted attributes** — personal data categories (`health_data`, `racial_ethnic_origin`) that must not be used for targeting under applicable policies + + + "I'd like to start certification module C2." + + +--- + +## C3: Creative workflows + +**~20 min** | Prerequisite: C2 + +How creative assets flow through AdCP: `build_creative`, `preview_creative`, `sync_creatives`. Cross-platform adaptation and the Sponsored Intelligence Protocol. + +### Reading list + + + + The creative protocol: assets, formats, manifests, creative agents. + + + Creative generation and transformation. + + + Previewing creatives before deployment. + + + Synchronizing creative assets with publisher platforms. + + + AI-generated creative workflows and best practices. + + + How creative agents charge: pricing discovery, build costs, and billing reconciliation. + + + Conversational brand experiences in AI assistants. + + + The Sponsored Intelligence protocol: sessions, messages, and offerings. + + + +### Key concepts + +- **Creative lifecycle** — `build_creative`, `preview_creative`, `sync_creatives` — callable on any agent implementing the Creative Protocol, including sales agents +- **Preview modes** — `preview_creative` supports single, batch, and variant requests — choose the mode that fits the workflow (S2 covers the tradeoffs) +- **Cross-platform adaptation** — agents adapt assets across display, video, audio, native, and broadcast formats. Broadcast manifests carry `industry_identifiers[]` (Ad-ID, ISCI) — S2 goes deep +- **Seller-side generation** — sales agents can generate creatives at serve time from a brief provided in the media buy +- **Creative pricing** — creative agents can charge for their services. When they do, `list_creatives` returns `pricing_options` from your account's rate card, and `build_creative` returns the cost incurred. See the [creative pricing specification](/dist/docs/3.0.13/creative/specification#pricing) for details. +- **Sponsored Intelligence** — brands participate in conversational AI with transparency and user control. SI is an [experimental surface](/dist/docs/3.0.13/reference/experimental-status) in AdCP 3.0 (feature id `sponsored_intelligence.core`); session lifecycle and UI components may change between 3.x releases with at least 6 weeks' notice. + + + "I'd like to start certification module C3." + + +--- + +## C4: Build project — your first buyer agent + +**~45 min** | Prerequisite: C3 + +Create a working buyer agent that discovers products and executes media buys. Use any AI coding assistant (Claude Code, Cursor, Copilot) with the [`@adcp/client`](/dist/docs/3.0.13/building/by-layer/L0/schemas) SDK. The skill tested is orchestrating a buying workflow correctly. + +### What you'll build + +- Account setup with `sync_accounts` +- Product discovery from at least 2 sellers +- Media buy creation with targeting and budget — including a fresh UUID v4 `idempotency_key` per logical buy, and correct retry behavior that resends the identical payload with the same key on network failure (the mechanics of what the seller does with the key — replay, conflict, expired — are taught in [S1: Media buy](/dist/docs/3.0.13/learning/specialist/media-buy)) +- Creative sync with at least 1 format +- Campaign monitoring via delivery reporting + +### How you'll validate + +Your buyer agent runs against the public test agent (`test-mcp`). Use `npx @adcp/client@latest` to execute tool calls and verify your agent handles the complete buying workflow: + +```bash +npx @adcp/client@latest test-mcp get_products '{"brief":"your campaign brief"}' +``` + +See [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) for CLI setup and the full testing workflow. + +### Validating across sellers + +Buyer agents don't claim specialisms — specialisms describe what sellers offer. But your buyer agent should handle every specialism it expects to transact against. The storyboards a seller passes (declared via `supported_protocols` and `specialisms` in their `get_adcp_capabilities`) tell you what behaviors to expect. + +Review the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) and note which specialisms your target sellers claim — that's your test matrix. + +### Assessment rubric + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Specification quality | 20% | Can you specify a buying workflow in AdCP terms? | +| Schema compliance | 25% | Agent requests and responses validate against schemas | +| Error handling | 15% | Handles seller errors and async responses | +| Design rationale | 20% | Can you explain orchestration and buying strategy? | +| Extension ability | 20% | Can you extend the agent with new buying capabilities? | + +Passing threshold: 70%. + +Any AI coding assistant is welcome. The build must demonstrate cross-role interaction. + + + "I'd like to start certification module C4." + + +--- + +## What's next + +After completing C1–C4, you've earned the **AdCP practitioner** credential. From here you can pursue specialist modules: + +- [S1: Media buy](/dist/docs/3.0.13/learning/specialist/media-buy) — transaction flows, pricing, orchestration +- [S2: Creative](/dist/docs/3.0.13/learning/specialist/creative) — asset workflows, format compliance +- [S3: Signals](/dist/docs/3.0.13/learning/specialist/signals) — measurement, attribution, optimization +- [S4: Governance](/dist/docs/3.0.13/learning/specialist/governance) — brand safety, campaign governance, compliance, policy registry +- [S5: Sponsored Intelligence](/dist/docs/3.0.13/learning/specialist/sponsored-intelligence) — conversational brand experiences diff --git a/dist/docs/3.0.13/learning/tracks/platform.mdx b/dist/docs/3.0.13/learning/tracks/platform.mdx new file mode 100644 index 0000000000..bf5a318236 --- /dev/null +++ b/dist/docs/3.0.13/learning/tracks/platform.mdx @@ -0,0 +1,226 @@ +--- +title: "Platform / intermediary track" +sidebarTitle: Platform track +description: "AdCP platform track (D1-D4): MCP server architecture, supply path verification, agent trust, RTB-to-AdCP migration patterns, and an infrastructure build project." +"og:title": "AdCP — Platform / intermediary track" +--- + +# Platform / intermediary track (D1–D4) + + +**Members only** — Requires Basics credential (A1–A3). Four modules, ~105 minutes total. + + +This track is for people building AdCP infrastructure: ad tech platforms, exchanges, data companies, and anyone connecting the ecosystem. You'll learn MCP server architecture, supply chain verification, RTB migration, and build working infrastructure in the build project. + +Completing this track (plus A1–A3) earns the **AdCP practitioner** credential. + +--- + +## D1: MCP server architecture + +**~20 min** | Prerequisite: A3 + +Technical deep dive on building an AdCP-compliant MCP server. Transport options (SSE, Streamable HTTP), tool definition patterns, OAuth/authorization flows, and account management. + +### Reading list + + + + Overview of what it takes to build an AdCP integration. + + + The definitive guide: tool calls, response format, available tools, context management. + + + OAuth 2.0 for agent authentication, token management, and operator credentials. + + + How brands, operators, agents, and accounts relate to each other. + + + Managing context across multi-turn agent interactions. + + + JSON Schemas, TypeScript types, and client SDKs for building faster. + + + +### Key concepts + +- **MCP server architecture** — exposing AdCP tasks as tools, handling auth, request routing +- **Transport options** — Streamable HTTP for most cases, SSE for real-time updates +- **Capability advertising** — `get_adcp_capabilities` so other agents know what you support +- **Account handling** — managing incoming `sync_accounts` from buyers + + + "I'd like to start certification module D1." + + +--- + +## D2: Supply path, trust, and property governance + +**~20 min** | Prerequisite: D1 + +Cryptographic signatures for supply chain verification. How platforms validate agent identity, detect fraud, and ensure trust. The relationship between AdCP and ads.cert. + +### Reading list + + + + Identity, authorization, and data enrichment for participants in AdCP. + + + Agent discovery and declaration — verifiable identity for every agent. + + + How publishers declare authorized sellers, and how buyers verify them. + + + Security implementation patterns for AdCP servers. + + + Account-level security, agent and account hierarchies, and access control. + + + The formal specification for property governance. + + + Multi-party validation: how platforms implement the governance flow for autonomous transactions. + + + The full technical specification: sync_plans, check_governance, report_plan_outcome, audit logs. + + + Community-maintained compliance policies that governance agents resolve and enforce. + + + +### Key concepts + +- **Agent identity verification** — domain ownership, cryptographic signatures, organizational registration +- **Supply path transparency** — full visibility into which agents handled each transaction +- **ads.cert relationship** — extending cryptographic verification from RTB to agent-to-agent interactions +- **Campaign governance architecture** — implement three-party validation: sync governance agents via `sync_governance`, call `check_governance` before executing, handle all statuses +- **Policy registry integration** — resolve policies by ID, integrate natural language policy text and exemplars into governance agent evaluation + + + "I'd like to start certification module D2." + + +--- + +## D3: RTB coexistence and migration + +**~20 min** | Prerequisite: D2 + +How AdCP coexists with existing programmatic infrastructure. Migration strategies for DSPs, SSPs, and exchanges. Running parallel systems during transition. + +### Reading list + + + + The real-time execution layer: context match, identity match, and cross-publisher activation. + + + The clearest view of how AdCP campaign workflows connect to impression-time execution. + + + Handling long-running operations — essential for bridging real-time and agentic systems. + + + Event-driven updates for delivery, status changes, and campaign modifications. + + + Error patterns, retry strategies, and graceful degradation. + + + Testing your implementation against sandbox agents before going live. + + + Common questions and answers from teams building AdCP integrations. + + + Deployment, fan-out, and provider configuration for the TMP Router. + + + How demand reaches AI assistants through the Trusted Match Protocol. + + + +### Key concepts + +- **Coexistence strategy** — run AdCP alongside OpenRTB, gradually migrating workflows +- **TMP as the bridge** — TMP connects planning-time media buys to serve-time decisions through two structurally separated operations (Context Match and Identity Match). The TMP Router fans out to configured providers in parallel and the publisher joins responses locally. This is how cross-publisher frequency capping, suppression, and suitability work at impression time +- **Router deployment and fan-out patterns** — how the TMP Router is deployed, provider configuration, and parallel fan-out to multiple match providers +- **Platform-specific migration** — DSPs wrap bidding logic, SSPs expose inventory, exchanges translate +- **Performance benchmarking** — compare agentic vs traditional on campaign performance, efficiency, cost + + + "I'd like to start certification module D3." + + +--- + +## D4: Build project — AdCP infrastructure + +**~45 min** | Prerequisite: D3 + +Build working AdCP infrastructure using any AI coding assistant (Claude Code, Cursor, Copilot) with the [`@adcp/client`](/dist/docs/3.0.13/building/by-layer/L0/schemas) SDK. Point your coding assistant at a [skill file](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) — choose `build-seller-agent` or `build-signals-agent` depending on what you want to build. This is the most ambitious build project. + +### What you'll build + +- MCP server with AdCP tool definitions +- `get_adcp_capabilities` implementation +- At least 3 AdCP tasks exposed as tools +- OAuth/authentication flow +- Proper error handling with AdCP error codes + +### How you'll validate + +Run the matching storyboard against your running agent: + +```bash +npx @adcp/client@latest storyboard run my-agent media_buy_seller +``` + +The storyboard validates protocol compliance across the complete workflow. See [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) for setup, debugging, and the full CLI reference. + +### Specialisms you can claim + +Platform agents often span multiple domains. Declare each protocol you implement in `supported_protocols` and each specialism you claim in `specialisms`. Some common combinations: + +- Full-stack sales platform: `supported_protocols: ["media_buy", "creative", "signals"]`, `specialisms: ["sales-guaranteed", "sales-non-guaranteed", "creative-ad-server"]` +- Signal platform: `supported_protocols: ["signals"]`, `specialisms: ["signal-marketplace"]` or `["signal-owned"]` +- Governance platform: `supported_protocols: ["governance"]`, `specialisms: ["content-standards", "property-lists", "collection-lists"]` + +See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for every specialism and the storyboards that verify each claim. + +### Assessment rubric + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Specification quality | 20% | Can you specify infrastructure in AdCP terms? | +| Schema compliance | 20% | Protocol compliance across all endpoints | +| Error handling | 15% | Proper error handling with recovery types and async | +| Design rationale | 25% | Can you reason about production architecture? | +| Extension ability | 20% | Can you extend the endpoint with new tasks? | + +Passing threshold: 70%. + + + "I'd like to start certification module D4." + + +--- + +## What's next + +After completing D1–D4, you've earned the **AdCP practitioner** credential. From here you can pursue specialist modules: + +- [S1: Media buy](/dist/docs/3.0.13/learning/specialist/media-buy) — transaction flows, pricing, orchestration +- [S2: Creative](/dist/docs/3.0.13/learning/specialist/creative) — asset workflows, format compliance +- [S3: Signals](/dist/docs/3.0.13/learning/specialist/signals) — measurement, attribution, optimization +- [S4: Governance](/dist/docs/3.0.13/learning/specialist/governance) — brand safety, campaign governance, compliance, policy registry +- [S5: Sponsored Intelligence](/dist/docs/3.0.13/learning/specialist/sponsored-intelligence) — conversational brand experiences diff --git a/dist/docs/3.0.13/learning/tracks/publisher.mdx b/dist/docs/3.0.13/learning/tracks/publisher.mdx new file mode 100644 index 0000000000..9a701c38e6 --- /dev/null +++ b/dist/docs/3.0.13/learning/tracks/publisher.mdx @@ -0,0 +1,246 @@ +--- +title: "Publisher / seller track" +sidebarTitle: Publisher track +description: "AdCP publisher track (B1-B4): build and operate a sales agent. Product catalog design, creative specs, delivery reporting, and a sales agent build project." +"og:title": "AdCP — Publisher / seller track" +--- + +# Publisher / seller track (B1–B4) + + +**Members only** — Requires Basics credential (A1–A3). Four modules, ~105 minutes total. + + +This track teaches you to build and operate the supply side of AdCP. You'll learn how sales agents represent publisher inventory, how buyer agents discover and evaluate your products, and how to expose delivery data and signals for measurement. The track culminates in a build project where you create a working sales agent. + +Completing this track (plus A1–A3) earns the **AdCP practitioner** credential. + +--- + +## B1: Designing your product catalog + +**~20 min** | Prerequisite: A3 + +How a hosted sales agent works: product discovery, catalog integration, capability advertising. Walkthrough of designing your product catalog — including collections and installments for content-centric inventory like podcasts, CTV series, and live events — and configuring what buyers can discover about you. + +### Reading list + + + + How buyer agents discover publisher inventory through `get_products`. + + + Task reference: request schema, response schema, and examples. + + + How to structure products: formats, pricing, targeting, and availability. + + + Model content-centric inventory: podcasts, CTV series, live events, and episodic content. + + + Catalog synchronization: 13 catalog types, feed field mappings. + + + How agents advertise their capabilities so other agents can discover what they offer. + + + Advertise your supported `adcp.major_versions`; reject mismatched requests with `VERSION_UNSUPPORTED`. + + + How publishers declare and authorize their properties for agentic transactions. + + + The state machine sellers must honor: `pending_creatives` → `pending_start` → `active` → `paused`/`completed`. + + + +### Key concepts + +- **Sales agent role** — your always-on, AI-powered sales team that responds to buyer queries +- **Product catalog design** — products, formats, pricing models, targeting options, availability, and shows/episodes for content-centric inventory +- **Catalog integration** — `sync_catalogs` for product data, 13 catalog types, feed field mappings +- **Capability advertising** — `get_adcp_capabilities` so buyers know what you support +- **Object-presence capability principle** — capabilities are declared by the presence of an object, not by booleans. If you support a feature, include the object; if you don't, omit it. Buyers check for the object, not for `"feature": true`. +- **Version advertising** — declare `adcp.major_versions` so multi-version buyers can migrate on their own schedule; validate incoming `adcp_major_version` and return `VERSION_UNSUPPORTED` when out of range +- **Lifecycle commitments** — `create_media_buy` MAY return `pending_creatives` or `pending_start`; MUST transition `pending_start` → `active` when the flight date arrives, and notify orchestrators via webhook. Rejection is only valid from the pending states +- **Reporting declaration** — every product MUST include `reporting_capabilities` (metrics, dimensions, cadence, measurement windows). Presence of this object declares `get_media_buy_delivery` support + + + "I'd like to start certification module B1." + + +--- + +## B2: Creative specifications and format support + +**~20 min** | Prerequisite: B1 + +Deep dive on `list_creative_formats` and how buyer agents discover what your inventory accepts. Guide to structuring creative specs, handling incoming creatives, and brand compliance. + +### Reading list + + + + What buyer briefs look like and how to structure products so agents can match them. + + + Real examples of product discovery queries from buyer agents. + + + How creative format specifications work — dimensions, file types, render requirements. + + + Format definitions, technical specs, and the renders structure. + + + Standard format IDs and how to implement them consistently. + + + How buyer agents refine searches and negotiate product parameters. + + + +### Key concepts + +- **Format declarations** — `list_creative_formats` tells buyers what you accept +- **Format vs manifest** — the format is what you accept, the manifest is what gets delivered +- **Handling incoming creatives** — `sync_creatives` and `build_creative` validation and approval +- **Brand compliance** — understanding incoming brand.json, disclosure positions + + + "I'd like to start certification module B2." + + +--- + +## B3: Measurement, signals, and optimization + +**~20 min** | Prerequisite: B2 + +How to expose delivery data, measurement signals, and optimization levers through your sales agent. Integration with measurement platforms, audience handling, and account management. + +### Reading list + + + + Delivery metrics: impressions, spend, completion rates, and performance data. + + + How buyer agents use delivery data for campaign optimization. + + + The signals protocol: audience segments, contextual signals, measurement data. + + + How publishers support impression-time execution for buyer use cases like cross-publisher frequency capping. + + + Deployment, fan-out, and provider configuration for publishers. + + + Signal discovery: what measurement data is available and how to expose it. + + + Enabling specific measurement on a campaign. + + + The commercial layer: advertisers, operators, authentication, and billing. + + + Seller-side governance checks: validate buyer purchases before executing them. + + + The governance validation task: intent checks, execution checks, statuses, and planned delivery. + + + +### Key concepts + +- **Delivery reporting** — accurate, timely delivery data via `get_media_buy_delivery` +- **Signals framework** — `get_signals` and `activate_signal` replace fragmented measurement vendor integrations +- **Audience handling** — `sync_audiences` for accepting buyer audience data +- **Execution integration** — publishers participate in TMP by sending Context Match requests (page content + available packages) and Identity Match requests (user token + package IDs) to the TMP Router, then joining the results locally. This enables cross-publisher frequency capping and impression-time activation without exposing user identity and page context to the buyer simultaneously +- **Account management** — `sync_accounts`, `list_accounts` for managing buyer relationships +- **Seller-side governance** — when a buyer account has `governance_agents` (synced via [`sync_governance`](/dist/docs/3.0.13/accounts/tasks/sync_governance)), call `check_governance` with `governance_context` + `purchase_type` + `planned_delivery` before confirming media buys +- **Planned delivery** — describe what you will actually deliver; the governance agent validates it against the buyer's plan + +### Exercise + +Given sample Context Match and Identity Match responses, determine which packages the publisher activates and what targeting key-values to set. + + + "I'd like to start certification module B3." + + +--- + +## B4: Build project — your first sales agent + +**~45 min** | Prerequisite: B3 + +Create a working sales agent that responds to real buyer queries. Point your AI coding assistant (Claude Code, Cursor, Copilot) at the [`build-seller-agent`](/dist/docs/3.0.13/building/by-layer/L4/build-an-agent) skill file — it generates an agent validated against AdCP storyboards, with optional [`comply_test_controller`](/dist/docs/3.0.13/building/by-layer/L3/comply-test-controller) scaffolding for deterministic lifecycle testing in sandbox mode. The skill tested is specifying correct AdCP behavior. + +### What you'll build + +- Product catalog with at least 3 products and `sync_catalogs` support, each product declaring `reporting_capabilities` +- `get_adcp_capabilities` that declares `adcp.major_versions` and validates `adcp_major_version` on incoming requests — including a truthful `capabilities.idempotency.replay_ttl_seconds` declaration +- Creative format support for at least 2 formats +- Handles `get_products`, `create_media_buy`, `list_creative_formats`, `get_media_buy_delivery` +- Account setup via `sync_accounts` +- Proper error responses for invalid requests — including `VERSION_UNSUPPORTED`, `GOVERNANCE_DENIED`, `TERMS_REJECTED`, `IDEMPOTENCY_CONFLICT`, and `IDEMPOTENCY_EXPIRED` where applicable +- Idempotency enforcement on every mutating request: reject missing or malformed `idempotency_key` with `INVALID_REQUEST` before touching business logic; cache only on success; scope per authenticated agent +- Lifecycle transitions: seller moves media buys through `pending_creatives` → `pending_start` → `active` and notifies via webhook on each transition + +### How you'll validate + +Run the `media_buy_seller` storyboard against your running agent: + +```bash +npx @adcp/client@latest storyboard run my-agent media_buy_seller +``` + +The storyboard exercises the complete buyer workflow — discovery, account sync, media buy, creatives, delivery — and validates every response. See [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) for setup and debugging. + +### Specialisms you can claim + +Declare `media_buy` in `supported_protocols` and choose the specialism that matches your inventory model. Each specialism has a compliance storyboard at `/compliance/{version}/specialisms/{id}/` that verifies your claim: + +- `sales-guaranteed` — guaranteed with human IO approval +- `sales-non-guaranteed` — auction-based +- `sales-proposal-mode` — proposal-negotiated +- `sales-catalog-driven` — catalog-driven commerce with conversion tracking +- `sales-broadcast-tv` — broadcast linear TV +- `sales-social` — social platform with self-service flows + +See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the full list. + +### Assessment rubric + +| Dimension | Weight | What Addie evaluates | +|-----------|--------|---------------------| +| Specification quality | 20% | Can you specify an agent in AdCP terms? | +| Schema compliance | 25% | Agent responses validate against AdCP schemas | +| Error handling | 15% | Handles invalid requests with proper AdCP errors | +| Design rationale | 20% | Can you explain and reason about design decisions? | +| Extension ability | 20% | Can you extend the agent with new capabilities? | + +Passing threshold: 70%. + +Any AI coding assistant is welcome (Claude, Cursor, Copilot, etc.). The skill tested is: "Can you specify correct AdCP behavior?" + + + "I'd like to start certification module B4." + + +--- + +## What's next + +After completing B1–B4, you've earned the **AdCP practitioner** credential. From here you can pursue specialist modules: + +- [S1: Media buy](/dist/docs/3.0.13/learning/specialist/media-buy) — transaction flows, pricing, orchestration +- [S2: Creative](/dist/docs/3.0.13/learning/specialist/creative) — asset workflows, format compliance +- [S3: Signals](/dist/docs/3.0.13/learning/specialist/signals) — measurement, attribution, optimization +- [S4: Governance](/dist/docs/3.0.13/learning/specialist/governance) — brand safety, campaign governance, compliance, policy registry +- [S5: Sponsored Intelligence](/dist/docs/3.0.13/learning/specialist/sponsored-intelligence) — conversational brand experiences diff --git a/dist/docs/3.0.13/measurement/taxonomy.mdx b/dist/docs/3.0.13/measurement/taxonomy.mdx new file mode 100644 index 0000000000..95351d9698 --- /dev/null +++ b/dist/docs/3.0.13/measurement/taxonomy.mdx @@ -0,0 +1,227 @@ +--- +title: Measurement Taxonomy +sidebarTitle: Measurement Taxonomy +description: "Three-layer model for AdCP measurement — metrics (delivery), verification (quality), attribution (outcomes) — and how each layer differs in source of truth, protocol home, and rate of change." +"og:title": "AdCP — Measurement Taxonomy" +--- + +# Measurement Taxonomy + +Measurement is three things, not one. Treating them as one bucket is the source of most confusion in measurement RFCs (SSAI, identity loss, AI-content provenance, clean rooms) and most schema bloat in delivery responses. AdCP separates them deliberately. + +The three layers — **metrics**, **verification**, and **attribution** — answer different questions, are attested by different parties, live in different places in the protocol, and evolve at different speeds. + +## The three layers + +| Layer | Question | Source of truth | Protocol home | Rate of change | +|---|---|---|---|---| +| **Metrics** | Did it happen? | Seller | Delivery reporting | Slow (decade-scale) | +| **Verification** | Did it count properly? | Third party | Performance standards + capabilities + manifest trackers + vendor-attested delivery values | Medium (environment-driven) | +| **Attribution** | Did it cause an outcome? | Buyer | Handoff hooks (event sources, log-level signals) | Fast (model-driven) | + +### 1. Metrics — "did it happen?" + +Delivery facts: impressions, completes, quartiles, clicks, spend, reach, frequency. Reach and frequency carry an explicit `reach_window` declaration (`cumulative` / `period` / `rolling`) so buyers know whether values can be summed across rows. + +For the full capability → commitment → optimization → delivery picture across both standard and vendor metric flows, see [Metric lifecycle](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting#metric-lifecycle). The same `(vendor, metric_id)` key flows through every surface for vendor-attested metrics — discovery, optimization capability, reporting capability, package commitment, optimization goal, performance standard, and delivery value. + +The seller is the source of truth — the seller served the ad and counts the event. Industry counting conventions (MRC, IAB) define what qualifies as an impression, what completes a video view, how to deduplicate. AdCP standardizes how sellers expose these counts; it does not redefine what they count. + +One nuance: some metrics in `available-metric.json` (ROAS, CPA, conversions, conversion_value, units_sold) are seller-reported but **attribution-derived** — the seller runs an attribution model over buyer-supplied event sources and reports the result. They live in delivery reporting because that's where every DSP and retail-media platform exposes them today, but the underlying event of truth is buyer-attested. Read these as *"attribution surfaced through delivery,"* not as pure delivery facts. The seller's number reflects the seller's attribution model over the buyer's events; reconciliation against buyer-side ground truth still belongs at the attribution boundary. + +In AdCP, metrics flow through delivery reporting: + +- [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) — current delivery state +- [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) — buyer-side observed performance +- [Optimization & Reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) — how reporting connects to optimization goals + +Metrics evolve slowly. Definitions are governed by industry bodies; new metrics (viewable impressions, attention seconds) appear on decade timescales. Schema pressure here is low. + +### 2. Verification — "did it count properly?" + +Quality attestation: viewability, invalid traffic (IVT), brand safety, geo accuracy, context fitness, ad-content provenance. + +The whole point of verification is that it is *not* the seller's word. Buyers contract with third-party measurement vendors (Moat, IAS, DoubleVerify) precisely so an independent party can confirm the impression met quality thresholds. Verification requires execution paths that survive the delivery environment — historically OMID and VPAID running client-side; in SSAI, [SIVA](https://iabtechlab.com/standards/siva/) as the server-side workaround. + +In AdCP, verification has structured surface across the buy lifecycle, anchored on the vendor's `brand.json` measurement-agent record: + +- **Discovery.** Buyers filter products by `required_performance_standards` ("70% MRC viewability by DoubleVerify"), `required_metrics`, and `required_vendor_metrics` on [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products). Sellers declare support via `reporting_capabilities.available_metrics`, `vendor_metrics`, and the `committed_metrics_supported` capability flag. +- **Commitment.** [`performance-standard.json`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) binds `metric` + `threshold` + `standard` (e.g., MRC vs GroupM viewability) + `vendor` into the buy contract. The vendor is a `BrandRef` resolving to the vendor's `brand.json` `agents[type='measurement']` record. When a performance standard is committed, *creatives MUST include `tracker_script` or `tracker_pixel` assets from that vendor* — the protocol enforces the path. `committed_metrics` snapshots the reporting contract on the package at `create_media_buy` (a unified discriminated array carrying both standard metrics from the closed `available-metric.json` enum and vendor-defined metrics anchored on `BrandRef`, with each entry timestamped via `committed_at`) and is append-only for the buy's lifetime. +- **Execution.** The [creative manifest](/dist/docs/3.0.13/creative/creative-manifests) carries trackers and macros (`vast_tracker`, `daast_tracker`, universal macros) that fire at delivery so the third-party vendor records the event. Whether they fire client-side or server-side is the seller's implementation detail; the buyer's contract is on the metric, not the path. +- **Reporting.** Standard verification metrics that have graduated into the closed `available-metric.json` enum (e.g., `viewability`) flow through their dedicated delivery scalars in [`delivery-metrics.json`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery). Non-graduated vendor-defined metrics flow through `vendor_metric_values` with `measurable_impressions` as the coverage denominator. Vendor attribution is anchored at the contract level via `committed_metrics` and `performance_standards.vendor`, not on the delivery row itself. `missing_metrics` surfaces accountability gaps when the seller didn't deliver on a committed metric — when `committed_metrics` is present, reconciliation is exact and timestamp-aware; when absent, `missing_metrics` falls back to the product's live `available_metrics` with no commitment-timestamp filter and under-reports gaps. Buyers SHOULD treat absence of `committed_metrics` as *"no audit-grade contract,"* not *"clean delivery."* + +The vendor's full *dashboard* lives at the vendor (Moat, IAS, DV, HUMAN, etc.), but the attested numbers flow back through AdCP delivery reports. Measurement agents are first-class identities — discoverable via `brand.json` `agents[type='measurement']` (the BrandRef anchor), with the metric catalog (`metric_id`, `standard_reference`, `accreditations[]`, `unit`, `methodology_url`, `methodology_version`) served by the agent's [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities) response under the `measurement` block. `brand.json` is the discovery point; the agent serves the catalog. + +#### Graduated verification metrics + +Verification metrics evolve at different rates of standardization, and the protocol gives them different levels of structural support based on where they sit in that gradient: + +- **Tier 1 — graduated.** Industry-published, MRC-or-equivalent accredited; multiple competing standards may exist. Gets a dedicated entry in the closed `available-metric.json` enum, a dedicated structured block in `delivery-metrics.json`, and (when standards are mutually incompatible) a `qualifier` slot on `committed_metrics` for disambiguation. **Viewability** is the canonical Tier 1 metric today — MRC and GroupM define materially different thresholds and require schema-enforced disambiguation via `qualifier.viewability_standard`. +- **Tier 2 — vendor-extended.** Vendor-defined metrics with no industry-published standard. Sellers declare reporting support via `reporting_capabilities.vendor_metrics` and optimization support via `vendor_metric_optimization.supported_metrics`; values flow via `vendor_metric_values`; goals bind to the vendor via `optimization_goals` with `kind: "vendor_metric"`; identity is anchored on the vendor's `BrandRef` and the catalog lives on the vendor's measurement-agent capabilities. **Attention scores, panel-based brand lift, panel demographics, emissions per impression** sit here today. +- **Tier 3 — asserted.** Free-form claims on products without structured vendor identity or standards-bearer attestation. Predates the BrandRef pattern and is being incrementally restructured upward. + +A metric graduates from Tier 2 to Tier 1 when an industry standards body publishes a measurement specification — anchored on standards-body publication, not vendor-count thresholds or informal convergence. The patterns that support Tier 1 (`qualifier` slot, dedicated delivery scalar, performance-standard binding) are reusable templates: viewability is the first instance, not a viewability-specific bespoke shape. + +#### Closed-loop topologies: seller-as-measurement-agent + +The graduated-metrics framing assumes the default measurement topology is *seller serves, third-party verifies* — DV/IAS attesting viewability while the publisher's ad server counts impressions. That's still the dominant pattern for traditional CTV, video, and display. But two channel classes have a different default: + +- **Retail-media closed loop**: Walmart Connect, Kroger Precision, Amazon DSP, Criteo Retail Media. The retailer serves the ad on its own surface, observes the click on its own surface, and observes the conversion (loyalty card, login, point-of-sale) on its own surface. The seller is also the measurement vendor; the trust model rests on the retailer's first-party data assets rather than third-party independence. +- **AI-native channels**: ChatGPT and other agentic-conversation surfaces inject ads directly into the conversation stream (server-side). Click navigation happens in an in-app webview the seller controls. Conversion attribution flows back through a seller-provided SDK (`oaiq.min.js` for OpenAI) deployed on the merchant's property. The seller is again also the measurement vendor. + +These are not degraded cases of third-party verification — they're a structurally different topology that the protocol supports cleanly via the existing primitives: + +- **Vendor identity is implicit when seller is vendor**: BrandRef anchors on the seller in `delivery_measurement.vendors`; vendor-scope `committed_metrics` entries point at the seller's measurement-agent capability; `performance_standards.vendor` (when present) names the seller. No additional schema needed. +- **Outcome metrics flow through the same vocabulary**: `conversion_value` + `qualifier.attribution_methodology: "deterministic_purchase"` + `qualifier.attribution_window: { interval: 30, unit: "days" }` cleanly expresses ChatGPT's attribution-token-based conversion attribution and Walmart Connect's `attributedSalesIn14Days`. No retail-media-specific schema, no AI-native-specific schema. +- **The `(metric_id, qualifier)` row shape handles both**: contract / diff / delivery / feedback all reconcile the same way regardless of whether the vendor is third-party or seller-as-vendor. + +What's missing today: a structured way for the seller to declare a **merchant-side SDK** the buyer deploys on their property to feed events back to the seller (the OAIQ pattern). Tracked as a separate RFC ([#3889](https://github.com/adcontextprotocol/adcp/issues/3889)) — the existing primitives express *what's measured*; the SDK distribution / integration / supply-chain story is the gap. + +Verification evolves at medium pace. Environment shifts — CTV, SSAI, walled gardens, cookieless, AI-generated content, AI-native channels — drive new signal-loss problems and new protocols to recover them. Expect schema pressure on verification capabilities every one to three years. + +### 3. Attribution — "did it cause an outcome?" + +Buyer-side joins between delivery and outcomes: conversions, lift, multi-touch attribution, media mix modeling, incrementality. + +The seller doesn't know the conversion event. The buyer (or the buyer's measurement partner) holds outcome data and joins it to delivery. AdCP's role is making the join possible — exposing log-level signals, identity hooks, and handoff patterns to clean rooms — not running the model. + +In AdCP, attribution shows up at the *boundary*: + +- [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) — buyer pushes conversion event sources into seller platforms so the platform can optimize toward real outcomes +- [`log_event`](/dist/docs/3.0.13/media-buy/task-reference/log_event) — buyer-attested event delivery +- [Conversion Tracking](/dist/docs/3.0.13/media-buy/conversion-tracking/) — patterns that connect delivery to outcomes +- [Trusted Match](/dist/docs/3.0.13/trusted-match/) — identity resolution that makes the join possible without leaking PII + +The model itself (clean rooms, MMM, causal inference, agentic outcome attribution) lives entirely outside the protocol. + +Attribution evolves fastest. Clean-room patterns, MMM revival, causal AI, commerce-media attribution, and agentic outcome models all shift the attribution layer on quarter-to-year timescales. If attribution lived inside delivery schemas, it would force a schema break every cycle. + +## Why the separation matters + +Most measurement debates in the working group resolve faster once the layer is named: + +- **SSAI** ([#3759](https://github.com/adcontextprotocol/adcp/issues/3759)) is a *verification* problem. It does not change which metrics get reported; it changes which verification paths are valid and how rich the surviving signal is. The fix lives in capabilities + creative manifest trackers, not in delivery reporting. +- **Identity loss** (cookieless, IDFA deprecation, walled-garden signal collapse) shows up in *attribution*, not metrics. The seller still serves and counts impressions; the buyer's join to outcomes degrades. Fixes belong at the attribution boundary (clean rooms, [Trusted Match](/dist/docs/3.0.13/trusted-match/)), not in delivery payloads. +- **AI-content provenance** is a *verification* concern (was this ad what the brand approved?), not an attribution concern. It belongs alongside other verification capabilities — see [Provenance Verification](/dist/docs/3.0.13/governance/creative/provenance-verification) — not bolted onto outcome reporting. +- **Outcome-based optimization goals** (CPA, ROAS, custom events) are an *attribution* concern surfaced as an optimization input. They belong at the event-source boundary, where the buyer hands over what the platform should optimize toward. + +When a proposal puts an attribution concept (lift, ROAS, MMM input) into delivery reporting, or a verification concept (OMID, SIVA) into attribution hooks, push back. The layer mismatch almost always means the proposal will accumulate edge cases until it breaks. + +## Working rule of thumb + +When evaluating where a measurement field belongs, ask **who is the source of truth?** + +- The **seller** counts it → metric → delivery reporting +- A **third party** attests to it → verification → capabilities + creative manifest +- The **buyer** owns the outcome → attribution → event-source / log-level handoff + +This single question resolves most placement debates. If two layers seem to claim the same field, the field is probably two fields wearing one name — split it. + +## The atomic unit: `(metric_id, qualifier)` + +The protocol's measurement primitives reduce to one tuple, indexed and reconciled the same way: + +- **`committed_metrics` rows**: `{ scope, metric_id, qualifier, committed_at }` — what the seller agreed to populate ([#3576](https://github.com/adcontextprotocol/adcp/pull/3576), shipped) +- **`missing_metrics` rows**: `{ scope, metric_id, qualifier }` — what didn't show up ([#3576](https://github.com/adcontextprotocol/adcp/pull/3576), shipped) +- **`metric_aggregates` rows**: `{ metric_id, qualifier, value, …components }` — what was actually delivered, partitioned by qualifier ([#3848](https://github.com/adcontextprotocol/adcp/issues/3848), proposed) + +Reconciliation collapses to a join on `(metric_id, qualifier)`. For each `committed_metrics` row, find the matching `metric_aggregates` row; absent matches surface as `missing_metrics`. No bespoke per-metric reconciliation logic, no traversal asymmetry between contract and delivery. + +The `qualifier` vocabulary differs by surface: contract is closed (`additionalProperties: false`, today carrying only `viewability_standard`); delivery is a deliberate **superset** (e.g., `tracker_firing` exists as a transparency disclosure that buyers don't commit to but sellers can expose post-delivery). The asymmetry is named, not accidental — the buyer commits to what they share vocabulary on, the seller exposes path-level transparency on what was delivered. + +When a future qualifier (`completion_threshold`, attention methodology if it standardizes) needs structural support, it plugs into the existing slot. No parallel `*_by_*` fields, no new aggregation surface, no schema break. + +## Boundaries with Signals and Governance + +Measurement is not the only third-party-attestation surface in AdCP. [Signals](/dist/docs/3.0.13/signals/overview) and [Governance](/dist/docs/3.0.13/governance/overview) also involve third parties, also produce attested artifacts, and also evolve faster than the core media-buy primitives. The boundaries are real but the protocols overlap in vendor and in lifecycle — Signals' own key-concepts page notes that signals are used "for targeting or measurement," and that ambiguity is the boundary in question. + +The clearest separation is by lifecycle moment and the question being asked: + +| Lifecycle moment | Question | Protocol home | +|---|---|---| +| Pre-decision | What should we do? | Signals | +| Plan-time | Are we allowed to do this? | Governance (policy registry, plan check) | +| Delivery | Did it happen? Did it count? | Measurement (metrics, verification) | +| Post-delivery | What outcome did it cause? | Measurement (attribution) | +| Continuous | Is the audit trail intact? | Governance (audit trail) | + +The same vendor often plays in multiple lanes. DoubleVerify, for example, sells pre-bid brand-safety *signals*, post-delivery *verification* attestations, and content-classification feeds consumed by *governance* policy enforcement. The vendor is one entity; the protocol surfaces are three because the timing, sources of truth, and consumption patterns differ. + +### Where the lines are crisp + +- **Signals are predictive; measurement is descriptive.** A pre-bid viewability score is a signal — an estimate of how likely an impression is to be viewable. A post-delivery viewability rate is measurement. Same methodology family, different question. +- **Governance is normative; measurement is factual.** Governance asks "did this comply with the rules we set?" Measurement asks "what objectively happened?" An attribution model can disagree with a buyer's outcome goal without violating any policy; a brand-safety violation can occur even when delivery measured cleanly. +- **Signals are inputs, measurement is outputs, governance is constraints.** The buying decision consumes signals, is bounded by governance, and produces facts that measurement records. + +### Where the lines blur + +- A pre-bid brand-safety classifier is sold as a *signal*; the same vendor's post-delivery report is *verification*. Same input data, different protocol home — driven by *when* the data is consumed. +- A *governance* policy can require a *measurement* attestation as evidence ("this campaign must verify with an MRC-accredited vendor"). Measurement becomes a precondition for governance approval. +- *Signals* feed *attribution* models — audience segments and identity signals are inputs to the lift or MMM model that produces outcome estimates. + +These overlaps are not bugs. They reflect how the measurement-and-data industry actually works: vendors operate across the lifecycle, and an event in one layer often becomes input to another. The protocol's job is to keep the *interfaces* clean — same vendor, multiple roles, multiple endpoints — not to collapse the lifecycle into a single surface. + +## Worked example: third-party viewability commitment + +A buyer needs DoubleVerify viewability at the MRC threshold on a CTV campaign. SSAI is in scope; the buyer doesn't know or care which products use it. + +**1. Discovery.** Buyer calls `get_products` with: + +```json +{ + "required_performance_standards": [ + { + "metric": "viewability", + "threshold": 0.70, + "standard": "mrc", + "vendor": { "domain": "doubleverify.com" } + } + ] +} +``` + +Products that cannot support DV's measurement on this inventory — for whatever plumbing reason, including SSAI environments where DV's path is degraded — are silently filtered out (filter-not-fail). Sellers do not declare "I am SSAI"; they declare *"I can deliver this performance standard with this vendor on this product."* The plumbing is the seller's problem. + +**2. Commitment.** Buyer calls `create_media_buy`. The `performance_standards` enter the buy contract; per `performance-standard.json`, *creatives MUST include `tracker_script` or `tracker_pixel` assets from `doubleverify.com`*. The seller returns `committed_metrics` (a unified array carrying both standard and vendor entries, each with `committed_at`) snapshotting the contract — append-only for the lifetime of the buy. The viewability commitment carries `qualifier.viewability_standard: "mrc"` so MRC and GroupM never reconcile against each other. + +**3. Execution.** The creative manifest carries DV's tracker assets. They fire — client-side, server-side, OMID, SIVA, whatever path the seller chose to honor the commitment. The buyer doesn't see the path. + +**4. Reporting.** Per-buy `totals` populate the standard `viewability` block (the graduated Tier 1 surface — `measurable_impressions`, `viewable_impressions`, `viewable_rate`, `standard`). Cross-buy `aggregated_totals` partition by qualifier via `metric_aggregates` ([#3848](https://github.com/adcontextprotocol/adcp/issues/3848), proposed) — same atomic unit as the contract, joined on `(metric_id, qualifier)`. If the seller fails to deliver any committed metric, it appears in `missing_metrics` — an accountability breach, surfaced in-protocol. + +**What this example shows.** The buyer never asks *"is this SSAI?"* The question they actually have — *"can my chosen verification vendor produce trustworthy viewability on this inventory?"* — is answered structurally by whether the product passes the filter. The seller's plumbing is a private implementation detail bound by a contract they signed at create-time. SSAI, CSAI, in-app, web, DOOH all flow through the same surface; none gets a special schema. + +This is verification working the way the layer is supposed to work: the buyer specifies the *outcome they need* (vendor + standard + threshold), the seller commits or excludes themselves, and accountability is structural, not narrative. + +## Open questions + +The taxonomy clarifies what's distinct, but two questions sit on the boundaries. + +### Should measurement get a dedicated protocol surface? + +Measurement agents are already first-class: vendors are discoverable as `brand.json` `agents[type='measurement']` (the BrandRef anchor), publish their metric catalog via `get_adcp_capabilities.measurement.metrics[]`, are referenced by `BrandRef` from `performance-standard.vendor`, `vendor_metrics`, and `committed_metrics` (vendor-scope entries), and emit attested values through `delivery-metrics.viewability` (graduated standards) and `vendor_metric_values` (non-graduated vendor metrics). The pattern is *"discoverable agent identity consumed across multiple protocols,"* not *"no protocol home."* + +The open question is whether that distributed pattern is right or whether measurement deserves a *peer protocol* alongside Signals and Governance — with its own task surface (e.g., `register_measurement`, `attest_outcome`, `dispute_measurement`) and its own specification page. Today, the measurement-agent contract is implicit, defined by the union of where the BrandRef gets consumed. + +**Case for status quo (distributed).** Measurement vendors already operate via OMID, MRC accreditation, and vendor SDKs. The protocol's job is making them callable from buyer/seller flows, which it does. A dedicated protocol risks duplicating what already works. + +**Case for peer protocol.** Dispute resolution, recount, and signed measurement attestation as a primary artifact (rather than a vendor field on a delivery row) might benefit from common primitives. If measurement-agent capabilities expand beyond *"report a value"* — into in-flight signal-survival reporting, predictive measurability, or independent governance audit — the distributed pattern strains. + +This question doesn't need an abstract answer. It will resolve as soon as a measurement-vendor capability surfaces that doesn't fit the current pattern. + +### Where do pre-bid measurement signals live? + +A pre-bid viewability score (predicted likelihood that an impression will be viewable) is sold by the same vendors that produce post-delivery viewability measurement. Today, predictions are *signals* (consumed pre-decision); measurements are *verification* (consumed post-delivery). Same vendor, same methodology family, two protocol homes. This works because the *consumption pattern* differs — but it's worth watching whether the duplication cost outweighs the layering benefit, especially as predictive measurement and post-delivery measurement converge in real-time bidding contexts. + +### Where does conversation-context targeting fit? + +AI-native channels (ChatGPT and similar agentic-conversation surfaces) target ads using the conversation topic as the signal — no cookies, no fingerprinting, no audience graph. The same account gets different advertisers across different chat subjects; the prompt itself carries the targeting signal in real time. + +This is structurally a *signals*-layer pattern (predictive, pre-decision) but at a finer grain than traditional contextual signals (which targeted page URL or page content). It's closer to walled-garden engagement-signal targeting (Facebook News Feed) than to traditional contextual ads — except that the inventory is conversational text, not feed posts. + +AdCP's signals taxonomy doesn't model conversation-context targeting directly today. Whether it warrants a new signal type or fits within the existing `Contextual signals` category is an open question — the inventory shape (conversational vs page-based) and signal lifecycle (per-prompt vs per-pageview) differ, but the consumption pattern (pre-decision targeting input) is the same. + +## What this protocol does not do + +AdCP does not run measurement models. It does not adjudicate between competing verification vendors. It does not define MRC counting conventions. It does not store or normalize attribution outputs. + +What AdCP does is make the right *connection points* exist — so the seller's metrics are queryable, the verifier's path is declarable and executable, and the buyer's outcome data has a place to attach. The measurement industry that grew up over thirty years sits on top of those connection points; the protocol does not replace it. diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/accountability.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/accountability.mdx new file mode 100644 index 0000000000..f1d01cb47e --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/accountability.mdx @@ -0,0 +1,232 @@ +--- +title: Accountability +sidebarTitle: Accountability +description: "How buyers and sellers negotiate, enforce, and resolve performance standards, measurement terms, and cancellation policies in AdCP guaranteed buys." +"og:title": "AdCP — Accountability" +--- + +## 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/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.0.13/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 +{ + "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.0.13/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. diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/accounts-and-security.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/accounts-and-security.mdx new file mode 100644 index 0000000000..0215ba7523 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/accounts-and-security.mdx @@ -0,0 +1,61 @@ +--- +title: Accounts & Security +description: "AdCP accounts and security — authentication, rate cards, billing entities, and data isolation for multi-tenant media buying between buyer and seller agents." +"og:title": "AdCP — Accounts & Security" +--- + + +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.0.13/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.0.13/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.0.13/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 diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine.mdx new file mode 100644 index 0000000000..1349148e53 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine.mdx @@ -0,0 +1,425 @@ +--- +title: Agentic eXecution Engine (AXE) +description: "Agentic eXecution Engine (AXE) — AdCP's real-time execution layer for brand suitability enforcement, frequency capping, and dynamic audience targeting at impression time." +"og:title": "AdCP — Agentic eXecution Engine (AXE)" +testable: true +--- + + +AXE is deprecated. The [Trusted Match Protocol (TMP)](/dist/docs/3.0.13/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 +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 +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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +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.0.13/protocol/get_adcp_capabilities) response tells buyers which orchestrator's AXE endpoint the seller connects to: + +```json +{ + "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 +// 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: {AXEM} + +Creatives can receive AXE context data for dynamic rendering: + +```html + +``` + +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.0.13/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.0.13/media-buy/advanced-topics/targeting)** - Brief-based targeting and geographic overlays +- **[Signals Protocol](/dist/docs/3.0.13/signals/overview)** - Signal discovery and activation +- **[Universal Macros](/dist/docs/3.0.13/creative/universal-macros)** - Creative-level AXE integration +- **[Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design)** - Building orchestration platforms diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/billing-authority.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/billing-authority.mdx new file mode 100644 index 0000000000..85cf4f91af --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/billing-authority.mdx @@ -0,0 +1,182 @@ +--- +title: Billing Authority +sidebarTitle: Billing Authority +description: "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." +"og:title": "AdCP — Billing Authority" +--- + +## 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.0.13/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" +{ + "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" +{ + "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" +{ + "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.0.13/core/measurement-terms.json) — schema +- [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) — seller-side finality flags +- [`report_usage`](/dist/docs/3.0.13/accounts/tasks/report_usage) — buyer-side / third-party finality flags +- [Accountability](/dist/docs/3.0.13/media-buy/advanced-topics/accountability) — performance standards, makegood remedies, cancellation +- [Reporting capabilities and measurement windows](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/index.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/index.mdx new file mode 100644 index 0000000000..7db45ac72f --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/index.mdx @@ -0,0 +1,121 @@ +--- +title: Advanced Topics +sidebarTitle: Overview +description: "AdCP advanced topics — targeting, pricing models, accounts and security, sandbox testing, and the Trusted Match Protocol (TMP) for real-time execution." +"og:title": "AdCP — Advanced Topics" +--- + + +This section covers advanced AdCP features and implementation details for experienced users who need deep technical understanding of the protocol's sophisticated capabilities. + +Advanced topics include: + +- **Targeting Systems** - Sophisticated audience and placement targeting +- **Dimensional Modeling** - Unified system for categorization and reporting +- **Security & Access Control** - Multi-tenant security and permissions +- **Implementation Details** - Architecture decisions and design rationale +- **Development Tools** - Testing and development acceleration + +## Core Advanced Concepts + +### Targeting +AdCP uses a brief-first targeting philosophy with technical overlays for specific needs: + +- **[Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting)** - Brief-based targeting with geographic overlays and real-time signals +- **[Trusted Match Protocol (TMP)](/dist/docs/3.0.13/trusted-match)** - Real-time execution layer for package activation across web, mobile, CTV, AI assistants, and retail media. Replaces AXE. +- **[Agentic eXecution Engine (AXE)](/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine)** - Deprecated. See TMP. + +This approach enables natural language targeting specifications while supporting technical requirements for compliance and testing. + +### Security & Access Control +Enterprise-grade security features for multi-tenant environments: + +- **[Accounts & Security](/dist/docs/3.0.13/media-buy/advanced-topics/accounts-and-security)** - Multi-tenant security model and access control +- **[Policy Compliance](/dist/docs/3.0.13/media-buy/media-buys/policy-compliance)** - Automated compliance checking and enforcement + +### Implementation Architecture +Deep technical details for implementers: + +- **[Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design)** - Technical architecture for AdCP orchestrators + +## Development & Testing + +### Development Tools +Accelerate development with AdCP's sandbox capabilities: + +- **[Sandbox Mode](/dist/docs/3.0.13/media-buy/advanced-topics/sandbox)** - Run operations without real platform calls or spend +- **Simulated responses** for testing the full media buying lifecycle risk-free + +### Performance Optimization +Understanding and optimizing AdCP implementations: +- **Response time expectations** for different operation types +- **Caching strategies** for improved performance +- **Scalability considerations** for high-volume implementations + +## Integration Patterns + +### Multi-Platform Orchestration +Patterns for managing campaigns across multiple advertising platforms: +- **State synchronization** across platforms +- **Unified reporting** from diverse data sources +- **Cross-platform optimization** strategies + +### AI Agent Integration +Best practices for AI agent implementations: +- **Natural language processing** for brief interpretation +- **Learning from performance** data for optimization +- **Error handling** and recovery strategies + +## Advanced Targeting + +### Layered Targeting Approach +AdCP's targeting system supports multiple layers of refinement: + +1. **Product-level targeting** - Built into product definitions +2. **Package-level overlays** - Additional targeting refinements +3. **Real-time signals** - Dynamic targeting adjustments +4. **Frequency management** - Cross-campaign frequency control + +### Targeting Consistency +AdCP's targeting approach ensures consistency across the campaign lifecycle: +- **Briefs** communicate targeting intent during discovery +- **Products** include targeting capabilities in their definitions +- **Overlays** add geographic restrictions when needed +- **Signals** enable real-time targeting decisions + +This ensures alignment between targeting intent and campaign delivery. + +## Enterprise Features + +### Multi-Tenant Architecture +Support for agency and enterprise environments: +- **Account isolation** for data security +- **Shared resources** where appropriate +- **Permission hierarchies** for complex organizations + +### Compliance & Governance +Built-in features for regulatory compliance: +- **Audit trails** for all operations +- **Approval workflows** for sensitive operations +- **Data governance** controls and monitoring + +## Technical Deep Dives + +### Protocol Design Philosophy +Understanding the core principles behind AdCP: +- **MCP-based architecture** for AI-first interaction +- **Asynchronous by design** for real-world timing +- **Human-in-the-loop** when needed +- **Platform abstraction** for universal compatibility + +### Performance Characteristics +Detailed understanding of system performance: +- **Response time categories** and expectations +- **Scalability limits** and considerations +- **Resource optimization** strategies + +## Next Steps + +For practical application of these advanced concepts: +- **Review [Sandbox Mode](/dist/docs/3.0.13/media-buy/advanced-topics/sandbox)** for development best practices +- **Explore [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design)** for architecture guidance \ No newline at end of file diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models.mdx new file mode 100644 index 0000000000..89742b0a41 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models.mdx @@ -0,0 +1,877 @@ +--- +title: Pricing Models +description: "AdCP pricing models — CPM, CPCV, CPP, CPC, CPA, time-based, and DOOH pricing. Publisher-declared, buyer-selected model with rate card support." +"og:title": "AdCP — Pricing Models" +--- + + +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 +{ + "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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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`). +- `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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ "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 +{ "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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. + +## 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.0.13/media-buy/product-discovery/media-products) - Product model reference +- [Creating Media Buys](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) - How to select pricing when buying +- [Delivery Reporting](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) - Understanding metrics by pricing model +- [Glossary](/dist/docs/3.0.13/reference/glossary) - Pricing and metric definitions diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/sandbox.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/sandbox.mdx new file mode 100644 index 0000000000..6482289b84 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/sandbox.mdx @@ -0,0 +1,250 @@ +--- +title: Sandbox mode +description: "AdCP sandbox mode — test product discovery, campaign creation, creatives, and delivery with simulated data. No real spend or production side effects." +"og:title": "AdCP — Sandbox mode" +--- + +## 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 +{ + "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. + +### Implicit 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 +// 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 +// get_products — implicit sandbox +{ + "account": { + "brand": { "domain": "acme-corp.com" }, + "operator": "acme-corp.com", + "sandbox": true + }, + "brief": "Premium CTV inventory for Q2 campaign" +} +``` + +### Explicit accounts (`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 +// list_accounts — find sandbox accounts +{ + "sandbox": true +} +``` + +The seller returns pre-existing test accounts: + +```json +{ + "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 +// get_products — explicit sandbox +{ + "account": { "account_id": "acct_sandbox_acme_001" }, + "brief": "Premium CTV inventory for Q2 campaign" +} +``` + +### Quick reference + +| | Implicit (`require_operator_auth: false`) | Explicit (`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 +{ + "products": [...], + "sandbox": true +} +``` + +## Full lifecycle example (implicit account) + +This example shows the implicit account path. For explicit accounts, replace the natural key account reference with `{ "account_id": "acct_sandbox_acme_001" }` in each step. + +### 1. Discover products + +```json +// 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 +// 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 +// 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 +// 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 **explicit account sellers**: ensure your platform has pre-existing sandbox/test accounts that `list_accounts` can return when filtered with `sandbox: true`. + +For **implicit 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. diff --git a/dist/docs/3.0.13/media-buy/advanced-topics/targeting.mdx b/dist/docs/3.0.13/media-buy/advanced-topics/targeting.mdx new file mode 100644 index 0000000000..047e0c73c6 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/advanced-topics/targeting.mdx @@ -0,0 +1,855 @@ +--- +title: Targeting +description: "AdCP targeting — how natural language briefs replace taxonomy-based audience selection, with geographic overlays and real-time decisioning at impression time." +"og:title": "AdCP — Targeting" +testable: true +--- + + +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 +{ + "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. + +## 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.0.13/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.0.13/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 +{ + "brief": "Target users in New York, Los Angeles, and Chicago metro areas with premium display advertising for our luxury retail brand." +} +``` + +### Demographic Targeting +```json +{ + "brief": "Reach parents with children under 10 who are interested in educational content, focusing on weekend and evening viewing times." +} +``` + +### Contextual Targeting +```json +{ + "brief": "Place financial services ads adjacent to business and investment content, targeting affluent professionals during business hours." +} +``` + +### Behavioral Targeting +```json +{ + "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 +{ + "product_id": "premium_millennial_mobile", + "name": "Premium Millennial Mobile Package", + "description": "Mobile display inventory targeting millennials (25-40) across lifestyle and entertainment apps in top 25 US markets", + "targeting_description": "Ages 25-40, household income $50K+, interests in lifestyle/entertainment, mobile apps only, top 25 US metro areas", + "audience_size": "~2.5M monthly unique users", + "pricing": { + "cpm": 8.50, + "targeting_included": true + } +} +``` + +## 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 +// 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 +// 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 system (e.g., `us_zip`, `gb_outward`) — 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 +{ + "$schema": "/schemas/3.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/core/targeting.json", + "device_platform": ["ios", "android"] +} +``` + +**Available platforms** (defined in [`device-platform.json`](https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/core/targeting.json", + "device_type": ["mobile", "tablet"] +} +``` + +**Exclusion** — use `device_type_exclude` to exclude specific form factors: + +```json +{ + "$schema": "/schemas/3.0.13/core/targeting.json", + "device_type_exclude": ["dooh"] +} +``` + +**Available types** (defined in [`device-type.json`](https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/core/targeting.json", + "frequency_cap": { + "suppress": { "interval": 60, "unit": "minutes" } + } +} +``` + +**Impression cap per entity per window** — `max_impressions` + `per` + `window` limits total exposure: +```json +{ + "$schema": "/schemas/3.0.13/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 +{ + "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 +{ + "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 +- **Third-party audience segments** - "Auto intenders" or "Luxury shoppers" in brief text (use signals protocol for data provider segments) +- **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` | | +| Third-party audience segment (interest targeting) | | ✅ "Auto intenders" in brief, or use signals protocol | +| 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 `system` and `values` +- **Systems**: `us_zip`, `us_zip_plus_four`, `gb_outward`, `gb_full`, `ca_fsa`, `ca_full`, `de_plz`, `fr_code_postal`, `au_postcode`, `ch_plz`, `at_plz` +- **Example**: `[{ "system": "us_zip", "values": ["10001", "10002"] }]` +- **Use cases**: Hyper-local campaigns, postal-level restrictions +- **Note**: Seller must declare supported systems in `get_adcp_capabilities` + +### geo_postal_areas_exclude +- **Description**: Exclude specific postal areas from delivery +- **Format**: Array of objects, each with a `system` and `values` +- **Example**: `[{ "system": "us_zip", "values": ["90210"] }]` +- **Use cases**: RCT holdout zip codes, restricted delivery areas +- **Note**: Seller must declare supported systems in `get_adcp_capabilities` + +### 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.0.13/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.0.13/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.0.13/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.0.13/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` + +### 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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "$schema": "/schemas/3.0.13/core/targeting.json", + "geo_proximity": [ + { + "lat": 51.2277, + "lng": 6.7735, + "label": "Düsseldorf", + "travel_time": { "value": 2, "unit": "hr" }, + "transport_mode": "driving" + } + ] +} +``` + +```json +{ + "$schema": "/schemas/3.0.13/core/targeting.json", + "geo_proximity": [ + { + "lat": 51.4700, + "lng": -0.4543, + "label": "Heathrow Airport", + "radius": { "value": 30, "unit": "km" } + } + ] +} +``` + +```json +{ + "$schema": "/schemas/3.0.13/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.0.13/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 +{ + "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.0.13/trusted-match)** - Real-time execution layer for impression-time targeting, frequency capping, and brand suitability +- **[Signals Protocol](/dist/docs/3.0.13/signals/overview)** - Real-time targeting signals for brand suitability and contextual targeting +- **[Product Discovery](/dist/docs/3.0.13/media-buy/product-discovery/)** - How briefs lead to targeted product recommendations +- **[Example Briefs](/dist/docs/3.0.13/media-buy/product-discovery/example-briefs)** - Real examples of effective targeting briefs +- **[Policy Compliance](/dist/docs/3.0.13/media-buy/media-buys/policy-compliance)** - Automated compliance checking and enforcement diff --git a/dist/docs/3.0.13/media-buy/capability-discovery/implementing-standard-formats.mdx b/dist/docs/3.0.13/media-buy/capability-discovery/implementing-standard-formats.mdx new file mode 100644 index 0000000000..d1335d6974 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/capability-discovery/implementing-standard-formats.mdx @@ -0,0 +1,490 @@ +--- +title: Implementing Standard Format Support +description: "Implementing standard creative formats in AdCP — reference IAB formats from the AdCP creative reference agent instead of redefining display, video, and native specs." +"og:title": "AdCP — Implementing Standard Format Support" +--- + + +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 +{ + "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 +{ + "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 +{ + "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.0.13/creative/channels/display)** - IAB standard banner sizes (300x250, 728x90, 320x50, etc.) +- **[Video Formats](/dist/docs/3.0.13/creative/channels/video)** - Standard video ad specifications (15s, 30s, vertical, CTV) +- **[Audio Formats](/dist/docs/3.0.13/creative/channels/audio)** - Streaming audio and podcast insertion formats +- **[DOOH Formats](/dist/docs/3.0.13/creative/channels/dooh)** - Digital out-of-home billboard and transit specs +- **[Carousel Formats](/dist/docs/3.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "$schema": "/schemas/3.0.13/media-buy/list-creative-formats-response.json", + "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 +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" + } +} +``` + +**Publisher Sales Agent:** +```json +{ + "format_id": { + "agent_url": "https://youragent.com", + "id": "custom_format" + } +} +``` + +**DCO Platform:** +```json +{ + "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 +// 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 + // ✅ 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 +{ + "format_id": { + "agent_url": "https://youragent.com", + "id": "display_300x250_old" + } +} +``` + +**After:** +```json +{ + "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 +{ + "$schema": "/schemas/3.0.13/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.0.13/creative) - How formats, manifests, and agents work together +- [Creative Formats](/dist/docs/3.0.13/creative/formats) - Understanding format specifications and discovery +- [Channel Guides](/dist/docs/3.0.13/creative/channels/video) - Detailed format documentation by media type +- [list_creative_formats Task](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) - API reference for format discovery diff --git a/dist/docs/3.0.13/media-buy/capability-discovery/index.mdx b/dist/docs/3.0.13/media-buy/capability-discovery/index.mdx new file mode 100644 index 0000000000..765043792f --- /dev/null +++ b/dist/docs/3.0.13/media-buy/capability-discovery/index.mdx @@ -0,0 +1,84 @@ +--- +title: Capability Discovery +sidebarTitle: Overview +description: "AdCP capability discovery — understand what creative formats a seller supports and which properties they represent before buying media." +"og:title": "AdCP — Capability Discovery" +--- + + +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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/protocol/get_adcp_capabilities) to learn what the agent supports +2. **Understand Formats**: Call [`list_creative_formats`](/dist/docs/3.0.13/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.0.13/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.0.13/media-buy/task-reference/)** - Complete API documentation +- **[Product Discovery](/dist/docs/3.0.13/media-buy/product-discovery/)** - Finding advertising inventory +- **[Creatives](/dist/docs/3.0.13/media-buy/creatives/)** - Creative asset management +- **[Creative Protocol](/dist/docs/3.0.13/creative/)** - Creative agents and manifests +- **[Creative Channel Guides](/dist/docs/3.0.13/creative/channels/video)** - Format examples and patterns +- **[Creative Manifests](/dist/docs/3.0.13/creative/creative-manifests)** - Understanding creative specifications +- **[adagents.json Specification](/dist/docs/3.0.13/governance/property/adagents)** - Publisher authorization system (applies across all AdCP protocols) \ No newline at end of file diff --git a/dist/docs/3.0.13/media-buy/commerce-media.mdx b/dist/docs/3.0.13/media-buy/commerce-media.mdx new file mode 100644 index 0000000000..893f0e06cb --- /dev/null +++ b/dist/docs/3.0.13/media-buy/commerce-media.mdx @@ -0,0 +1,670 @@ +--- +title: Commerce Media +description: "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." +"og:title": "AdCP — Commerce Media" +--- + + +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.0.13/media-buy/advanced-topics/accounts-and-security) | +| Advertiser account with retailer | `account_id` + `list_accounts` | [Accounts & Agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) | +| Brand's product catalog | `brand.product_catalog` | [Brand identity](/dist/docs/3.0.13/brand-protocol/brand-json) | +| GTIN / SKU selection for promotion | Catalog selectors (`ids`, `gtins`, `tags`) on `Catalog` | [Catalogs](/dist/docs/3.0.13/creative/catalogs) | +| Sponsored product listing | Product with catalog-rendered creative | [Creative Formats](/dist/docs/3.0.13/creative/formats) | +| On-site display / video | Product with standard `format_ids` | [Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products) | +| Retailer first-party audience | Brief-based; named segments via `data_provider_signals` | [Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting) | +| ROAS target / max conversions | `optimization_goals` on package | [Optimization & Reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) | +| Closed-loop measurement | `outcome_measurement` + `conversion_tracking` on product | [Delivery Reporting](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) | +| Attribution window (14d click, 1d view) | `attribution_window` in delivery response | [Delivery Reporting](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) | +| ROAS / attributed revenue | `roas`, `conversion_value` in delivery metrics | [Delivery Reporting](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) | +| In-store attribution | `by_action_source` with `in_store` | [Delivery Reporting](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) | +| Store locations / store locator | Store catalog (seller-provided or buyer-synced) | [Catalogs — Stores](/dist/docs/3.0.13/creative/catalogs#stores) | +| Real-time inventory / in-stock | Inventory catalog via `sync_catalogs` | [Catalogs](/dist/docs/3.0.13/creative/catalogs) | +| Proximity / catchment targeting | Store catchment areas (isochrone, radius, GeoJSON) | [Catalogs — Catchment areas](/dist/docs/3.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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": [ + { + "placement_id": "homepage_hero", + "name": "Homepage Hero Banner", + "format_ids": [{ "agent_url": "https://creative.adcontextprotocol.org", "id": "display_970x250" }] + }, + { + "placement_id": "homepage_sidebar", + "name": "Homepage Sidebar", + "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 +{ + "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": [ + { + "placement_id": "checkout_screen", + "name": "Checkout Lane Screens", + "format_ids": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_1080x1920" }, + { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } + ] + }, + { + "placement_id": "endcap_screen", + "name": "Aisle End-Cap Displays", + "format_ids": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_1080x1920" } + ] + }, + { + "placement_id": "pharmacy_waiting", + "name": "Pharmacy Waiting Area", + "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 +{ + "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.0.13/building/by-layer/L2/account-state) for how this fits the broader setup sequence. + +```json +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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: + +**`outcome_measurement`** describes the attribution methodology: +```json +{ + "outcome_measurement": { + "type": "attributed_sales", + "attribution": "deterministic_purchase", + "window": { "interval": 14, "unit": "days" }, + "reporting": "daily_api" + } +} +``` + +**`conversion_tracking`** declares action sources and whether the retailer manages measurement: +```json +{ + "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.0.13/building/by-layer/L2/account-state) — How catalogs, event sources, and campaigns build on the account +- [Accounts & Agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) — Account setup and billing relationships +- [Media Channel Taxonomy](/dist/docs/3.0.13/reference/media-channel-taxonomy) — `retail_media` channel definition +- [Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products) — Product model reference +- [Catalogs](/dist/docs/3.0.13/creative/catalogs) — Product, inventory, store, and promotion feeds +- [Brand identity](/dist/docs/3.0.13/brand-protocol/brand-json) — Product catalog and brand identity +- [Pricing Models](/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models) — CPC, CPM, flat rate details +- [Optimization & Reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) — `optimization_goals` and conversion optimization +- [Delivery Reporting](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) — Commerce attribution metrics +- [Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting) — Brief-based targeting approach diff --git a/dist/docs/3.0.13/media-buy/conversion-tracking/index.mdx b/dist/docs/3.0.13/media-buy/conversion-tracking/index.mdx new file mode 100644 index 0000000000..0c61da0ade --- /dev/null +++ b/dist/docs/3.0.13/media-buy/conversion-tracking/index.mdx @@ -0,0 +1,698 @@ +--- +title: Conversion Tracking & Optimization Goals +description: "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." +"og:title": "AdCP — Conversion Tracking & Optimization Goals" +testable: true +--- + + +Conversion tracking in AdCP connects advertising spend to business outcomes. Two tasks handle the lifecycle: [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) configures where events come from, and [`log_event`](/dist/docs/3.0.13/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 +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 events are collected — a website pixel, mobile SDK, server-to-server integration, or CRM import. + +Configure event sources with [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources). You provide an `event_source_id`, optional `name`, `event_types`, 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) | Type of event source (website pixel, app SDK, etc.) | +| `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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/media-buy/task-reference/log_event): + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 | +| `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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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) | +| `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 subscribed to a service or newsletter | +| `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`) | + +## 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 source health + +Sellers that evaluate event source quality include a `health` object on each source in the [`sync_event_sources`](/dist/docs/3.0.13/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.0.13/core/event-source-health.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 +// 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.0.13/core/measurement-readiness.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/core/optimization-goal.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "kind": "metric", + "metric": "clicks" +} +``` + +**Cost per click**: + +```json +{ + "kind": "metric", + "metric": "clicks", + "target": { "kind": "cost_per", "value": 2.00 }, + "priority": 2 +} +``` + +**Minimum click-through rate**: + +```json +{ + "kind": "metric", + "metric": "clicks", + "target": { "kind": "threshold_rate", "value": 0.001 }, + "priority": 2 +} +``` + +**Minimum attention time**: + +```json +{ + "kind": "metric", + "metric": "attention_seconds", + "target": { "kind": "threshold_rate", "value": 5.0 }, + "priority": 3 +} +``` + +**Maximize engagements** (social reactions, comments, shares, story opens, overlay taps): + +```json +{ + "kind": "metric", + "metric": "engagements" +} +``` + +**Completed views with duration threshold** (6-second views on TikTok): + +```json +{ + "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 | +| `attention_seconds` | seconds/impression | 5.0 (5s attention) | Attention time per impression | +| `attention_score` | score/impression | 40.0 (vendor-specific) | Attention score per impression | +| `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 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. | + +### Target kinds + +All target kinds across both goal types: + +| `target.kind` | Metric goals | Event goals | Description | +|---|---|---|---| +| `cost_per` | Cost per click/view/etc. | Cost per conversion event | `spend / count` | +| `threshold_rate` | Minimum per-impression 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 | Minimum attention time | `kind: "metric"`, `metric: "attention_seconds"` + `target: { kind: "threshold_rate", value: 5.0 }` | +| 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/subscribers | `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 +"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`, `start_trial`, `contact`, `schedule`, `donate`, `submit_application` | Safe as standalone optimization targets | +| 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 +{ + "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.0.13/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 +{ + "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.0.13/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.0.13/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.0.13/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.0.13/protocol/get_adcp_capabilities). When unsupported, use a single event source per goal. +4. **Send events** — Use [`log_event`](/dist/docs/3.0.13/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.0.13/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.0.13/media-buy/task-reference/get_media_buy_delivery) shows per-item metrics (impressions, spend, clicks, conversions). + +## Related documentation + +- [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) — Configure event sources +- [`log_event`](/dist/docs/3.0.13/media-buy/task-reference/log_event) — Send conversion events +- [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy#campaign-with-conversion-optimization) — Set optimization goals on packages +- [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) — Monitor conversion metrics +- [Pricing Models](/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models#cpa-cost-per-acquisition) — CPA billing (pay per conversion) diff --git a/dist/docs/3.0.13/media-buy/creatives/index.mdx b/dist/docs/3.0.13/media-buy/creatives/index.mdx new file mode 100644 index 0000000000..571c09f053 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/creatives/index.mdx @@ -0,0 +1,180 @@ +--- +title: Creative Lifecycle +description: "AdCP creative lifecycle — discover supported formats, sync creative assets to sellers, manage libraries, and track approval status across campaigns." +"og:title": "AdCP — Creative Lifecycle" +--- + + +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.0.13/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. + +### Creative Library Management +Use [`list_creatives`](/dist/docs/3.0.13/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 +// 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 +// 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.0.13/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.0.13/creative/task-reference/sync_creatives)** - Bulk creative management with upsert semantics +- **[`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives)** - Advanced creative library querying and filtering +- **[`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats)** - Understanding format requirements +- **[Brand identity](/dist/docs/3.0.13/brand-protocol/brand-json)** - Brand identity and asset management +- **[Creative Formats](/dist/docs/3.0.13/creative/formats)** - Understanding format specifications and discovery +- **[Creative Channel Guides](/dist/docs/3.0.13/creative/channels/video)** - Format examples across video, display, audio, DOOH, and carousels +- **[Asset Types](/dist/docs/3.0.13/creative/asset-types)** - Understanding asset roles and specifications diff --git a/dist/docs/3.0.13/media-buy/index.mdx b/dist/docs/3.0.13/media-buy/index.mdx new file mode 100644 index 0000000000..9f99a64843 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/index.mdx @@ -0,0 +1,258 @@ +--- +title: Media buy protocol +sidebarTitle: Overview +"og:image": /images/walkthrough/media-buy-01-sams-desk.png +description: "AdCP media buy protocol walkthrough — follow a campaign from brief to delivery across CTV, display, and audio using one unified workflow." +"og:title": "AdCP — Media buy protocol" +--- + +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 +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` to each seller | +| 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.0.13/media-buy/advanced-topics/accountability) | +| Makegoods | `makegood_policy` in `measurement_terms` — see [Accountability](/dist/docs/3.0.13/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 +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 +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 +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.0.13/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.0.13/governance/campaign/index) for the complete specification. + + + +```javascript +// 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.0.13/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 +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.0.13/media-buy/product-discovery/media-products) — briefs, wholesale mode, proposals, and refinement +- **Campaign lifecycle**: [Managing media buys](/dist/docs/3.0.13/media-buy/media-buys/index) — status transitions, updates, and approvals +- **Optimization**: [Delivery and reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) — metrics, dimensional breakdowns, and feedback loops +- **Governance**: [Campaign governance](/dist/docs/3.0.13/governance/overview) — how the three-party trust model protects Sam's spend +- **Creative**: [Creative walkthrough](/dist/docs/3.0.13/creative/index) — how Maya builds the creatives Sam uses +- **Real-time matching**: [Trusted Match Protocol](/dist/docs/3.0.13/trusted-match) — how packages activate at serve time via Context Match and Identity Match +- **Get certified**: The [Buyer track](/dist/docs/3.0.13/learning/tracks/buyer) teaches the full media buy workflow through interactive modules diff --git a/dist/docs/3.0.13/media-buy/media-buys/index.mdx b/dist/docs/3.0.13/media-buy/media-buys/index.mdx new file mode 100644 index 0000000000..e414528573 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/media-buys/index.mdx @@ -0,0 +1,569 @@ +--- +title: Media Buy Lifecycle +description: "AdCP media buy lifecycle — create, update, monitor, and optimize campaigns across sellers using create_media_buy, update_media_buy, and get_media_buys tasks." +"og:title": "AdCP — Media Buy Lifecycle" +--- + + +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.0.13/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) +- 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`, or `active` 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 Upload Phase +Once created, the media buy requires creative assets via [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives): + +- **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` +- **Creative Assignment**: Attach assets from the creative library +- **Delivery Monitoring**: Track pacing and performance metrics with [`get_media_buy_delivery`](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/media-buy/task-reference/create_media_buy) for the full request shape): + +```json +{ + "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 Targeting + +When a product defines multiple placements, buyers can assign different creatives to different placements while still purchasing the entire product. + +**Key Points:** +- **Packages buy the entire product** - you cannot target individual placements at the package level +- Placement targeting happens **only at the creative assignment level** +- Creatives without `placement_ids` run on **all placements** in the product + +**Example Workflow:** + +1. **Product defines placements:** +```json +{ + "product_id": "news_site_premium", + "placements": [ + { + "placement_id": "homepage_banner", + "name": "Homepage Banner", + "format_ids": [{"agent_url": "...", "id": "display_728x90"}] + }, + { + "placement_id": "article_sidebar", + "name": "Article Sidebar", + "format_ids": [{"agent_url": "...", "id": "display_300x250"}] + } + ] +} +``` + +2. **Buyer creates package (buys entire product) and assigns different creatives to each placement:** +```json +{ + "product_id": "news_site_premium", + "creative_assignments": [ + { + "creative_id": "creative_homepage", + "placement_ids": ["homepage_banner"] + }, + { + "creative_id": "creative_article", + "placement_ids": ["article_sidebar"] + } + ] +} +``` + +3. **Or assign one creative to all placements (omit placement_ids):** +```json +{ + "product_id": "news_site_premium", + "creative_assignments": [ + { + "creative_id": "creative_universal" + // No placement_ids = runs on all placements in the product + } + ] +} +``` + +**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.0.13/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 +{ + "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.0.13/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 │ resume + │ ▲ │ + (pause) │ │ (resume) │ + ▼ │ │ + paused ────────────────────────────────────┘ + │ + active ───────┼──────────────▶ completed (terminal) + paused ───────┘ ▲ flight ends / goal met / budget exhausted + +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 +- **`pending_start`**: Ready to serve, waiting for flight date +- **`active`**: Running and delivering impressions +- **`paused`**: Temporarily stopped by buyer or seller +- **`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.0.13/creative/creative-libraries#creative-state-and-assignment-state-are-separate). + +**Order confirmation**: A successful `create_media_buy` response constitutes order confirmation. The response includes `confirmed_at` with the confirmation timestamp. + +**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`. + +**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`, or `active`); 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 +{ + "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 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. + + +## 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.0.13/media-buy/task-reference/update_media_buy) with `canceled: true`: + +```json +{ + "media_buy_id": "mb_12345", + "canceled": true, + "cancellation_reason": "Campaign strategy changed" +} +``` + +Cancel a single package within an active media buy: + +```json +{ + "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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/media-buy/product-discovery/)** - Finding inventory for media buys +- **[Task Reference](/dist/docs/3.0.13/media-buy/task-reference/)** - Complete API documentation +- **[Creatives](/dist/docs/3.0.13/media-buy/creatives/)** - Creative asset management +- **[Orchestrator Design Guide](/dist/docs/3.0.13/building/operating/orchestrator-design)** - Implementation best practices diff --git a/dist/docs/3.0.13/media-buy/media-buys/lifecycle.mdx b/dist/docs/3.0.13/media-buy/media-buys/lifecycle.mdx new file mode 100644 index 0000000000..3a0568cc17 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/media-buys/lifecycle.mdx @@ -0,0 +1,277 @@ +--- +title: Media Buy Lifecycle Flow +description: "Step-by-step sequence from product discovery through delivery, including the guaranteed-deal IO acceptance path and creative sync timing." +"og:title": "AdCP — Media Buy Lifecycle Flow" +--- + +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.0.13/media-buy/media-buys/). + +## Standard flow + +Every media buy follows four steps: + +```mermaid +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] + D --> G[sync_creatives] + 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 +``` + +1. **`get_products`** — discover available inventory matching your brief. +2. **`create_media_buy`** — submit packages; the seller validates and confirms. +3. **`sync_creatives`** — assign creative assets to packages that need them. +4. **Delivery** — the buy enters `active`, accrues impressions, and 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`, or `active` once the operation completes. See [Asynchronous Operations](/dist/docs/3.0.13/media-buy/media-buys/#asynchronous-operations-and-human-in-the-loop). + + +### Transitions + +```mermaid +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) + + pending_creatives --> pending_start : sync_creatives + pending_start --> active : flight date reached + + 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 +{ + "media_buy_id": "mb_12345", + "status": "active", + "revision": 3, + "valid_actions": ["pause", "cancel", "update_budget", "update_dates", "update_packages", "add_packages", "sync_creatives"] +} +``` + +Always pass `revision` in `update_media_buy` calls. The seller rejects with `CONFLICT` if the revision has changed since your last read. + +## Guaranteed / PG deal variation + +Products with `delivery_type: "guaranteed"` require contractual commitment before delivery begins. The flow diverges after `create_media_buy`: + +```mermaid +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[sync_creatives\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.0.13/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. If the flight date is in the future, it enters `pending_start`. + +If no creatives are assigned at creation, the buy enters `pending_creatives`. Delivery cannot begin until `sync_creatives` is called to assign at least one creative per package. + +### 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, `sync_creatives` calls for that package return `CREATIVE_REJECTED`. 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. 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. + +## 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 `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 +{ + "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.0.13/building/by-layer/L3/webhooks#persistent-channel-contract) for delivery semantics (at-least-once, no-ordering, coalescence, replay via 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`. Campaign-level decision (replace vs. wait for resubmit) depends on creative pool composition and is left to the buyer. | +| `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.0.13/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: 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.0.13/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 from approved → rejected on an active buy, verifies the buy reflects `health: impaired` with a matching `impairments[]` entry, recovers 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.0.13/protocol/snapshot-and-log) for the read-side rules that tie `impairments[]` (snapshot) to the `impairment` push (log). + + +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. See [creative state and assignment state](/dist/docs/3.0.13/creative/creative-libraries#creative-state-and-assignment-state-are-separate). + + +## See also + +- [Media Buy Lifecycle](/dist/docs/3.0.13/media-buy/media-buys/) — full lifecycle reference: campaign structure, package model, async operations +- [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) — task reference with request parameters, response shapes, and examples +- [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) — assign and update creative assets on active packages +- [Accountability](/dist/docs/3.0.13/media-buy/advanced-topics/accountability) — performance standards, measurement terms, makegood resolution +- [Optimization & Reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) — delivery monitoring, dimensional reporting, campaign updates diff --git a/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting.mdx b/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting.mdx new file mode 100644 index 0000000000..6adb297a24 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting.mdx @@ -0,0 +1,1138 @@ +--- +title: Optimization & Reporting +description: "AdCP optimization and reporting — monitor delivery metrics, analyze dimensional breakdowns, share performance feedback, and optimize active media buys." +"og:title": "AdCP — Optimization & Reporting" +--- + + +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.0.13/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.0.13/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.0.13/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.0.13/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 + +## 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 +{ + "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 +{ + "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.0.13/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.0.13/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`**: 100% video completions +- **`video_completions`**: Completed video views +- **`completion_rate`**: Video completion percentage +- **`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 +- **`leads`**: Leads generated +- **`reach`**: Unique reach +- **`frequency`**: Average frequency per individual +- **`grps`**: Gross Rating Points (for CPP billing) +- **`viewability`**: Viewability data (measurable_impressions, viewable_impressions, viewable_rate, standard). Separates MRC and GroupM standards. +- **`engagement_rate`**: Platform-specific engagement metric +- **`quartile_data`**: Video quartile completion data (q1-q4) +- **`dooh_metrics`**: DOOH-specific metrics (loop plays, screens, venue breakdown) +- **`cost_per_click`**: Cost per click + +Buyers can optionally request a subset via `requested_metrics` to reduce payload size and focus on relevant KPIs. + +#### 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 use the same payload structure as [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) with additional metadata: + +```json +{ + "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.00, + "clicks": 250, + "ctr": 0.002 + }, + "by_package": [...] + } + ] +} +``` + +**Fields:** +- **`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`**: Array of media buy delivery data (may contain multiple media buys aggregated by publisher) + +#### Timezone Handling + +**All reporting MUST use UTC.** This eliminates DST complexity, simplifies reconciliation, and ensures consistent 24-hour reporting periods. + +```json +{ + "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 +{ + "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 +{ + "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). + +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 +{ + "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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +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 +{ + "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 +{ + "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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{"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 +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 +{ + "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 +{ + "type": "record", + "name": "MediaBuyDelivery", + "namespace": "org.adcp.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.0.13/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.0.13/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 +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 +{ + "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 +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.0.13/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.0.13/media-buy/conversion-tracking/) for the complete setup flow, and [Optimization Goals](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/creative/task-reference/sync_creatives) 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.0.13/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.0.13/media-buy/task-reference/provide_performance_feedback) task: + +```json +{ + "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 | Key fields | Capability flag | +|-----------|----------------|------------|-----------------| +| Geography | `by_geo` | `geo_level`, `geo_code`, `geo_name` | `supports_geo_breakdown` (object — declares available levels/systems) | +| Device type | `by_device_type` | `device_type` | `supports_device_type_breakdown` | +| Device platform | `by_device_platform` | `device_platform` | `supports_device_platform_breakdown` | +| Audience | `by_audience` | `audience_id`, `audience_source`, `audience_name` | `supports_audience_breakdown` | +| Placement | `by_placement` | `placement_id`, `placement_name` | `supports_placement_breakdown` | + +Each breakdown entry inherits all fields from `delivery-metrics` (impressions, spend, clicks, conversions, etc.) plus its dimension-specific identifier fields. Every entry requires at minimum its identifier(s), `impressions`, and `spend`. + +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.0.13/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.0.13/media-buy/task-reference/get_media_buy_delivery)** - Retrieve delivery reports +- **[`update_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/update_media_buy)** - Modify campaigns based on performance +- **[Media Buy Lifecycle](/dist/docs/3.0.13/media-buy/media-buys)** - Complete campaign management workflow +- **[Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting)** - Brief-based targeting and overlays \ No newline at end of file diff --git a/dist/docs/3.0.13/media-buy/media-buys/policy-compliance.mdx b/dist/docs/3.0.13/media-buy/media-buys/policy-compliance.mdx new file mode 100644 index 0000000000..1e70ba431e --- /dev/null +++ b/dist/docs/3.0.13/media-buy/media-buys/policy-compliance.mdx @@ -0,0 +1,210 @@ +--- +title: Policy Compliance +description: "AdCP policy compliance — how publishers enforce brand safety and regulatory checks during product discovery and media buy creation. Includes policy types and violation handling." +"og:title": "AdCP — Policy Compliance" +--- + + +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 +{ + "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.0.13/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 +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 +{ + "products": [...], + "policy_compliance": { + "status": "allowed" + } +} +``` + +### `restricted` +The brand category requires manual approval before products can be shown. + +```json +{ + "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 +{ + "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 +{ + "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.0.13/media-buy/task-reference/get_products) - Product discovery with policy checks +- [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) - Media buy creation with validation +- [Accounts & Security](/dist/docs/3.0.13/media-buy/advanced-topics/accounts-and-security) - Authentication and authorization diff --git a/dist/docs/3.0.13/media-buy/product-discovery/brief-expectations.mdx b/dist/docs/3.0.13/media-buy/product-discovery/brief-expectations.mdx new file mode 100644 index 0000000000..c1b7fd323a --- /dev/null +++ b/dist/docs/3.0.13/media-buy/product-discovery/brief-expectations.mdx @@ -0,0 +1,396 @@ +--- +title: Brief Expectations +description: "AdCP brief expectations — how to write campaign briefs for get_products. Required fields, optional details, and implementation guidance for publishers and buyer agents." +"og:title": "AdCP — Brief Expectations" +--- + + +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 +{ + "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 +{ + "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 +{ + "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.0.13/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 (Standard Catalog) +```json +{ + "brand": {"domain": "acmecorp.com"}, + "buying_mode": "wholesale", + "filters": { + "delivery_type": "non_guaranteed", + "standard_formats_only": true + } +} +``` +**Publisher Response**: Return standard catalog products (broad reach inventory optimized for scale). No AI curation, no proposals. Buyer applies their own targeting. + +### Minimal Brief +```json +{ + "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 +{ + "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 +{ + "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 +{ + "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 +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 +{ + "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 +{ + "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.0.13/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. diff --git a/dist/docs/3.0.13/media-buy/product-discovery/collections-and-installments.mdx b/dist/docs/3.0.13/media-buy/product-discovery/collections-and-installments.mdx new file mode 100644 index 0000000000..fda6e4440b --- /dev/null +++ b/dist/docs/3.0.13/media-buy/product-discovery/collections-and-installments.mdx @@ -0,0 +1,1015 @@ +--- +title: Collections and installments +description: "AdCP collections and installments — model podcast, CTV, and streaming content as dimensions on media products. Enables collection-level targeting and installment-specific ad placement." +"og:title": "AdCP — Collections and installments" +testable: true +--- + +{/* Using latest because this schema is not yet released in any version. + Update to correct version alias after the next release. */} + +A [product](/dist/docs/3.0.13/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.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "product_id": "pinnacle_run_of_collection", + "name": "Pinnacle Challenge Run of Show", + "collections": [{ "publisher_domain": "acmestreaming.example.com", "collection_ids": ["pinnacle_challenge"] }], + "placements": [ + { "placement_id": "pre_roll", "name": "Pre-roll" } + ], + "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 +{ + "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 +{ + "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": [ + { "placement_id": "pre_roll", "name": "Pre-roll" }, + { "placement_id": "mid_roll", "name": "Mid-roll" } + ], + "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 +{ + "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 +{ + "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": [ + { "placement_id": "pre_roll", "name": "Pre-roll (15s)" }, + { "placement_id": "mid_roll", "name": "Mid-roll (30s)" } + ], + "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 +{ + "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": [ + { "placement_id": "pre_roll", "name": "Pre-roll (15s)" } + ], + "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 +{ + "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": [ + { "placement_id": "pre_roll", "name": "Pre-roll (30s)" }, + { "placement_id": "mid_roll", "name": "Mid-roll host read (60s)" } + ], + "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 +{ + "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": [ + { "placement_id": "in_game_overlay", "name": "In-game overlay (10s)" }, + { "placement_id": "halftime", "name": "Halftime feature (60s)" } + ], + "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 +{ + "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.0.13/governance/property/adagents). There is no separate collection discovery endpoint — collections surface through product discovery and `adagents.json` crawling. + +## 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.0.13/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 +{ + "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 +{ + "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_playlist_id`, `amazon_title_id`, `roku_channel_id`, `pluto_channel_id`, `tubi_id`, `peacock_id`, `tiktok_id`, `twitch_channel`. Other: `domain`, `substack_id`. + +## 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.0.13/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 +{ + "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 +{ + "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": [ + { "placement_id": "full_page", "name": "Full Page" }, + { "placement_id": "dps", "name": "Double Page Spread" }, + { "placement_id": "ifc", "name": "Inside Front Cover" } + ], + "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.0.13/media-buy/product-discovery/media-products) — the full product model +- [Print ads](/dist/docs/3.0.13/creative/channels/print) — print-specific creative formats, physical dimensions, bleed, and DPI +- [brand.json](/dist/docs/3.0.13/brand-protocol/brand-json) — talent identity and brand safety evaluation +- [Product discovery](/dist/docs/3.0.13/media-buy/product-discovery) — how buyers discover inventory via `get_products` diff --git a/dist/docs/3.0.13/media-buy/product-discovery/example-briefs.mdx b/dist/docs/3.0.13/media-buy/product-discovery/example-briefs.mdx new file mode 100644 index 0000000000..2a7630b604 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/product-discovery/example-briefs.mdx @@ -0,0 +1,271 @@ +--- +sidebar_label: Example Briefs +title: Example Campaign Briefs +description: "AdCP example campaign briefs — annotated examples from minimal to advanced showing how natural language briefs drive product discovery in get_products." +"og:title": "AdCP — Example Campaign Briefs" +--- + + +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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/media-buy/product-discovery/brief-expectations) - How publishers process briefs +- [Creative Formats](/dist/docs/3.0.13/creative/formats) - Understanding format specifications and discovery +- [Media Buy Lifecycle](/dist/docs/3.0.13/media-buy/media-buys) - Campaign execution workflow +- [Product Discovery](/dist/docs/3.0.13/media-buy/product-discovery) - How briefs influence product selection \ No newline at end of file diff --git a/dist/docs/3.0.13/media-buy/product-discovery/index.mdx b/dist/docs/3.0.13/media-buy/product-discovery/index.mdx new file mode 100644 index 0000000000..1091c760e2 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/product-discovery/index.mdx @@ -0,0 +1,132 @@ +--- +title: Product Discovery +sidebarTitle: Overview +description: "AdCP product discovery — use natural language campaign briefs to find advertising inventory across publishers. Covers brief writing, product structures, and refinement." +"og:title": "AdCP — Product Discovery" +--- + + +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.0.13/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.0.13/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.0.13/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.0.13/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. When products reference collections, buyers resolve full collection metadata (talent, genre, content ratings, distribution identifiers) from the collection creator's or publisher's `adagents.json`. + +Understand the complete product structure in [Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products). For collection-centric inventory like podcasts, CTV series, and live events, see [Collections and Installments](/dist/docs/3.0.13/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.0.13/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.0.13/governance/property/index). See [`get_products` — Property Governance](/dist/docs/3.0.13/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** for required creative specifications +2. **Use [`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats)** to get detailed format requirements +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.0.13/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.0.13/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.0.13/media-buy/task-reference/get_products)**: ~60 seconds (AI processing) +- **[`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats)**: ~1 second (database lookup) + +## Next Steps + +After discovering products: +1. **[Create Media Buy](/dist/docs/3.0.13/media-buy/media-buys/)** - Build campaigns from selected products +2. **[Creative Planning](/dist/docs/3.0.13/media-buy/creatives/)** - Prepare assets matching format requirements +3. **[Task Reference](/dist/docs/3.0.13/media-buy/task-reference/)** - Detailed API documentation for implementation + +## Related Documentation + +- **[Brief Expectations](/dist/docs/3.0.13/media-buy/product-discovery/brief-expectations)** - Comprehensive guide to brief structure +- **[Example Briefs](/dist/docs/3.0.13/media-buy/product-discovery/example-briefs)** - Real-world campaign brief patterns +- **[Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products)** - Understanding product model and attributes +- **[Refinement](/dist/docs/3.0.13/media-buy/product-discovery/refinement)** - Iterating on products and proposals with change requests +- **[Proposals](/dist/docs/3.0.13/media-buy/product-discovery/media-products#proposals)** - Structured media plans with budget allocations +- **[`get_products` Task](/dist/docs/3.0.13/media-buy/task-reference/get_products)** - Complete API reference \ No newline at end of file diff --git a/dist/docs/3.0.13/media-buy/product-discovery/media-products.mdx b/dist/docs/3.0.13/media-buy/product-discovery/media-products.mdx new file mode 100644 index 0000000000..9bc10ed091 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/product-discovery/media-products.mdx @@ -0,0 +1,1237 @@ +--- +title: Media Products +description: "AdCP media products — the core sellable unit in the protocol. Covers product structure, pricing options, delivery types, format references, and catalog-driven inventory." +"og:title": "AdCP — Media Products" +--- + + +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.0.13/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.0.13/reference/media-channel-taxonomy). +- `format_ids` (list[FormatID], required): Structured format ID references. See [Creative Formats](/dist/docs/3.0.13/creative/formats). +- `placements` (list[Placement], optional): Specific ad placements within this product. When provided, buyers can target individual placements when assigning creatives. 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.0.13/media-buy/product-discovery/collections-and-installments). +- `episodes` (list[Episode], optional): Specific episodes available within this product. See [Collections and installments](/dist/docs/3.0.13/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 (e.g., "Google Ad Manager with IAS viewability"). When absent, buyers should apply their own measurement defaults. See [Delivery Measurement](#delivery-measurement). +- `outcome_measurement` (OutcomeMeasurement, optional): Business outcome measurement included with the product — incremental sales lift, brand lift studies, etc. Common for retail media products. +- `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. +- `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.0.13/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 +{ + "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.0.13/protocol/get_adcp_capabilities). + +```json +{ + "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.0.13/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; buyers must use a supported currency +- **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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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.0.13/media-buy/advanced-topics/accountability). + +### Outcome Measurement Object + +For products that include outcome measurement (common in retail media): +```json +{ + "type": "incremental_sales_lift", + "attribution": "deterministic_purchase", + "window": { "interval": 30, "unit": "days" }, + "reporting": "weekly_dashboard" +} +``` + +### CreativePolicy Object + +Defines creative requirements and restrictions: +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/core/creative-policy.json", + "co_branding": "required", + "landing_page": "retailer_site_only", + "templates_available": true +} +``` + +### Placements + +Products can optionally declare specific ad placements within their inventory. When placements are provided: + +- **Buyers purchase the entire product** - Packages always target the whole product, not individual placements +- **Placement targeting happens at creative assignment** - Different creatives can be assigned to different placements +- **Omitting placement targeting** - Creatives without placement_ids run on all placements in the package +- **Reuse registered IDs when available** - If the publisher declares canonical `placements` in `adagents.json`, product placements SHOULD reuse those `placement_id` values +- **Preserve registry semantics** - When a product reuses a registered `placement_id`, it is referring to that same placement. The product may narrow `format_ids` 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 + +#### Placement Object Structure + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/core/placement.json", + "placement_id": "homepage_banner", + "name": "Homepage Banner", + "description": "Above-the-fold banner on the homepage", + "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 +{ + "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": [ + { + "placement_id": "homepage_banner", + "name": "Homepage Banner", + "tags": ["homepage", "display", "premium"], + "format_ids": [{"agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90"}] + }, + { + "placement_id": "article_sidebar", + "name": "Article Sidebar", + "tags": ["article", "display"], + "format_ids": [{"agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250"}] + } + ], + "delivery_type": "guaranteed", + "pricing_options": [...] +} +``` + +When creating a media buy, buyers can assign different creatives to different placements: + +```json +{ + "packages": [ + { + "product_id": "news_site_premium", + "creative_assignments": [ + { + "creative_id": "creative_1", + "placement_ids": ["homepage_banner"] + }, + { + "creative_id": "creative_2", + "placement_ids": ["article_sidebar"] + } + ] + } + ] +} +``` + +See [Creative Assignment and Placement Targeting](/dist/docs/3.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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. + +### 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 directly. + +### 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**: Buyers execute proposals directly via `create_media_buy` with a `proposal_id` +- **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 +{ + "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.0.13/media-buy/task-reference/get_products#refinement) for the full workflow and examples. + +### Executing a Proposal + +To execute a proposal, provide the `proposal_id` and `total_budget` in `create_media_buy`: + +```json +{ + "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 +- `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, 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 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 +{ + "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 +{ + "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. + +#### 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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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. + +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.0.13/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.0.13/media-buy/product-discovery) - How to discover products using natural language +- [Media Buys](/dist/docs/3.0.13/media-buy/media-buys) - How to purchase products +- [Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting) - Detailed targeting options +- [Creative Formats](/dist/docs/3.0.13/creative/formats) - Understanding format specifications and discovery diff --git a/dist/docs/3.0.13/media-buy/product-discovery/refinement.mdx b/dist/docs/3.0.13/media-buy/product-discovery/refinement.mdx new file mode 100644 index 0000000000..410d7ea43f --- /dev/null +++ b/dist/docs/3.0.13/media-buy/product-discovery/refinement.mdx @@ -0,0 +1,287 @@ +--- +title: Refinement +description: "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." +"og:title": "AdCP — Refinement" +--- + +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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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" } + ] +} +``` + +## 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.0.13/building/by-layer/L0/schemas) 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 | +| `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.0.13/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.0.13/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.0.13/media-buy/task-reference/get_products#refinement) — API reference with request/response schemas +- [Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products) — product model and proposal structure +- [Media Buy Specification](/dist/docs/3.0.13/media-buy/specification#get_products) — normative requirements +- [Orchestrator Design](/dist/docs/3.0.13/building/operating/orchestrator-design) — building buyer-side agents diff --git a/dist/docs/3.0.13/media-buy/specification.mdx b/dist/docs/3.0.13/media-buy/specification.mdx new file mode 100644 index 0000000000..97f3a8d7d2 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/specification.mdx @@ -0,0 +1,558 @@ +--- +title: Media Buy Specification +description: "AdCP media buy specification — transport layer, task definitions, JSON schemas, authentication, and compliance requirements for agent-to-agent advertising." +"og:title": "AdCP — Media Buy Specification" +sidebarTitle: Specification +--- + + +**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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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 │ resume + │ ▲ │ + (pause) │ │ (resume) │ + ▼ │ │ + paused ────────────────────────────────────┘ + │ + active ───────┼──────────────▶ completed (terminal) + paused ───────┘ ▲ flight ends / goal met / budget exhausted + +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`, `pending_creatives`, or `pending_start` from `create_media_buy` (seller's choice based on platform setup time) +- 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 `create_media_buy` response constitutes **order confirmation**. Sales agents MUST include `confirmed_at` in the response. +- Sales agents MUST include `revision` in create and update responses. The revision number MUST increment on every state change or update. +- `active` ↔ `paused` transitions use `update_media_buy` with `paused: true` or `paused: false` +- `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; the creatives themselves remain in the creative library.** When a media buy transitions to `rejected` or `canceled`, all package-creative assignments on that media buy are released. The creatives persist in the creative library per [assignment state and creative state](/dist/docs/3.0.13/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, regardless of submission path (inline creatives submitted on the rejected/canceled `create_media_buy` enter the library on the same lifecycle as `sync_creatives` uploads). +- **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 reusability SHOULD call `list_creatives` to verify the creative is still in the library and observe its current status. Package-scoped deadlines (`creative_deadline`) on the prior package have no bearing on the creative's eligibility for a new buy. +- **Retention.** Sales agents 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. + +**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.0.13/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 via `sync_creatives`, 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` +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.0.13/media-buy/get-products-request.json) / [`media-buy/get-products-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-products-response.json) + +**Reference**: [`get_products` task](/dist/docs/3.0.13/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.0.13/media-buy/list-creative-formats-request.json) / [`media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/list-creative-formats-response.json) + +**Reference**: [`list_creative_formats` task](/dist/docs/3.0.13/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.0.13/media-buy/create-media-buy-request.json) / [`media-buy/create-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/create-media-buy-response.json) + +**Reference**: [`create_media_buy` task](/dist/docs/3.0.13/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` — a successful response constitutes order confirmation +- 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.0.13/media-buy/update-media-buy-request.json) / [`media-buy/update-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/update-media-buy-response.json) + +**Reference**: [`update_media_buy` task](/dist/docs/3.0.13/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.0.13/media-buy/sync-catalogs-request.json) / [`media-buy/sync-catalogs-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-catalogs-response.json) + +**Reference**: [`sync_catalogs` task](/dist/docs/3.0.13/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.0.13/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.0.13/creative/task-reference/list_creatives). + + +### sync_creatives + + +`sync_creatives` is defined in the [Creative Protocol](/dist/docs/3.0.13/creative/specification#sync_creatives). Any agent that hosts a creative library implements `sync_creatives` as part of the Creative Protocol. See the [`sync_creatives` task reference](/dist/docs/3.0.13/creative/task-reference/sync_creatives). + + +### get_media_buys + +**Schema**: [`media-buy/get-media-buys-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buys-request.json) / [`media-buy/get-media-buys-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buys-response.json) + +**Reference**: [`get_media_buys` task](/dist/docs/3.0.13/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 `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` | `cancel`, `sync_creatives` | +| `pending_start` | `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). + +`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." +- 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.0.13/media-buy/get-media-buy-delivery-request.json) / [`media-buy/get-media-buy-delivery-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buy-delivery-response.json) + +**Reference**: [`get_media_buy_delivery` task](/dist/docs/3.0.13/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.0.13/media-buy/provide-performance-feedback-request.json) / [`media-buy/provide-performance-feedback-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/provide-performance-feedback-response.json) + +**Reference**: [`provide_performance_feedback` task](/dist/docs/3.0.13/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.0.13/media-buy/sync-event-sources-request.json) / [`media-buy/sync-event-sources-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-event-sources-response.json) + +**Reference**: [`sync_event_sources` task](/dist/docs/3.0.13/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.0.13/media-buy/log-event-request.json) / [`media-buy/log-event-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/log-event-response.json) + +**Reference**: [`log_event` task](/dist/docs/3.0.13/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 + +{/* Using latest because sync-audiences schemas are not yet released in any version. + Update to correct version alias after the next release. */} +**Schema**: [`media-buy/sync-audiences-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-audiences-request.json) / [`media-buy/sync-audiences-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-audiences-response.json) + +**Reference**: [`sync_audiences` task](/dist/docs/3.0.13/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.0.13/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.0.13/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: re-negotiate via `get_products` in `refine` mode against the existing `proposal_id` to obtain a fresh quote reflecting the new parameters, then resubmit the update against the new `proposal_id`. 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_REQUIRED`: Authentication is required to access this resource + +## 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.0.13/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.0.13/media-buy/get-products-request.json) | get_products request | +| [`media-buy/get-products-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-products-response.json) | get_products response | +| [`media-buy/create-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/create-media-buy-request.json) | create_media_buy request | +| [`media-buy/create-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/create-media-buy-response.json) | create_media_buy response | +| [`media-buy/update-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/update-media-buy-request.json) | update_media_buy request | +| [`media-buy/update-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/update-media-buy-response.json) | update_media_buy response | +| [`media-buy/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/list-creative-formats-request.json) | list_creative_formats request | +| [`media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/list-creative-formats-response.json) | list_creative_formats response | +| [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creatives-request.json) | list_creatives request (Creative Protocol) | +| [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/list-creatives-response.json) | list_creatives response (Creative Protocol) | +| [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-request.json) | sync_creatives request (Creative Protocol) | +| [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.0.13/creative/sync-creatives-response.json) | sync_creatives response (Creative Protocol) | +| [`media-buy/get-media-buy-delivery-request.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/media-buy/provide-performance-feedback-request.json) | provide_performance_feedback request | +| [`media-buy/provide-performance-feedback-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/provide-performance-feedback-response.json) | provide_performance_feedback response | diff --git a/dist/docs/3.0.13/media-buy/task-reference/create_media_buy.mdx b/dist/docs/3.0.13/media-buy/task-reference/create_media_buy.mdx new file mode 100644 index 0000000000..4763cac488 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/create_media_buy.mdx @@ -0,0 +1,1397 @@ +--- +title: create_media_buy +description: "create_media_buy task — create advertising campaigns in AdCP from discovered products. Handles packages, budgets, flight dates, governance rules, and approval workflows." +"og:title": "AdCP — create_media_buy" +testable: true +--- + + +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.0.13/media-buy/create-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/create-media-buy-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/create-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/create-media-buy-response.json) + +## Quick Start + +Create a simple media buy with two packages: + + + +```javascript JavaScript test=false +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema } from '@adcp/client'; + +// 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 +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 +npx @adcp/client@latest \ + https://test-agent.adcontextprotocol.org/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.0.13/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 proposal from `get_products` to execute. Alternative to providing packages. | +| `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.0.13/brand-protocol/brand-json) | +| `start_time` | string | Yes | `"asap"` or ISO 8601 date-time | +| `end_time` | string | Yes | ISO 8601 date-time (UTC unless timezone specified) | +| `invoice_recipient` | [BusinessEntity](/dist/docs/3.0.13/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. + +### 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` | +| `pricing_option_id` | string | Yes | Pricing option ID from product's `pricing_options` array | +| `format_ids` | FormatID[] | Yes | Format IDs that will be used - must be supported by product | +| `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.0.13/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.0.13/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 and assign (`creative_id` must not already exist in library) | +| `context` | object | No | Opaque correlation data echoed unchanged in the package response. Use to map seller-assigned `package_id` back to your internal line items, campaign structure, or tracking state. | +| `measurement_terms` | [MeasurementTerms](/dist/docs/3.0.13/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.0.13/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. | + +## Response + +### Success Response + +| Field | Description | +|-------|-------------| +| `media_buy_id` | Seller's unique identifier | +| `confirmed_at` | ISO 8601 timestamp of order confirmation. A successful response constitutes confirmation. | +| `creative_deadline` | ISO 8601 timestamp for creative upload deadline | +| `packages` | Array of created packages with complete state. Packages may include per-package `creative_deadline` when different from the media buy deadline. | + +### 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`. + +| Field | Description | +|-------|-------------| +| `status` | Literal `"submitted"` — discriminates this shape from the sync success branch, whose `status` carries a `MediaBuyStatus` value (`pending_creatives` / `pending_start` / `active`). | +| `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. + +## Common Scenarios + +### Campaign with Targeting + +Add geographic restrictions and frequency capping: + + + +```javascript JavaScript test=false +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema } from '@adcp/client'; + +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 +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.0.13/media-buy/task-reference/sync_catalogs). + +**Job campaign with synced job catalog:** + +```json test=false +{ + "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 +{ + "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.0.13/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. + +### Campaign with Inline Creatives + +Upload creatives at the same time as creating the campaign: + + + +```javascript JavaScript test=false +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema } from '@adcp/client'; + +// 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', 'video_completions'] + } +}); + +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 +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', 'video_completions'] + } + ) + + 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 +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema } from '@adcp/client'; + +// 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 +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.0.13/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, useful for mapping `package_id` back to your internal line items + +**Mapping to internal campaign and line item IDs:** + +```json test=false +{ + "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 +{ + "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` | +| `FORMAT_INCOMPATIBLE` | Format not supported by product | Check product's `format_ids` field | +| `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 library | Use a different `creative_id`, assign existing creatives via `creative_assignments`, or update via `sync_creatives` | + +Example error response: + +```json +{ + "errors": [{ + "code": "FORMAT_INCOMPATIBLE", + "message": "Product 'prod_d979b543' does not support format 'display_728x90'", + "field": "packages[0].format_ids", + "suggestion": "Use display_300x250_image, display_300x250_html, or display_300x250_generative formats" + }] +} +``` + +## Key Concepts + +### Format Specification + +Format IDs are **required** for each package because: +- Publishers create placeholder creatives in ad servers +- Both parties know exactly what creative assets are needed +- Validation ensures products support requested formats +- Progress tracking shows which assets are missing + +See [Format Workflow](#format-workflow) below for complete details. + +### Brand reference + +The `brand` field identifies the advertiser for policy compliance and business purposes. + +```json +{ + "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.0.13/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.0.13/media-buy/advanced-topics/pricing-models). + +### 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.0.13/trusted-match)) + +See [Targeting](/dist/docs/3.0.13/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 they support) +3. Validate compatibility → Ensure products support desired formats +4. create_media_buy → Specify formats (REQUIRED) + └── Publisher creates placeholders + └── Clear creative requirements established +5. sync_creatives → Upload actual files matching formats +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 format example: + +```json +{ + "errors": [{ + "code": "FORMAT_INCOMPATIBLE", + "message": "Product 'ctv_sports_premium' does not support format 'audio_standard_30s'", + "field": "packages[0].format_ids", + "supported_formats": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_30s" }, + { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_15s" } + ] + }] +} +``` + +### Flight date validation + +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 +{ + "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.0.13/building/by-layer/L3/task-lifecycle). + + + + +### Immediate Success (`completed`) + +The task completed synchronously. No async handling needed. + +**Request:** +```javascript test=false +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 +{ + "status": "completed", + "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", + "product_id": "prod_ctv_sports" + } + ] +} +``` + +### Long-Running (`submitted`) + +The task is queued for manual approval. Configure a webhook to receive updates. + +**Request with webhook:** +```javascript test=false +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 +{ + "status": "submitted", + "task_id": "task_abc123", + "message": "Budget exceeds auto-approval limit. Sales review required (2-4 hours)." +} +``` + +**Webhook POST when approved:** +```json +{ + "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", + "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 +{ + "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 +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 +{ + "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 +{ + "status": "working", + "taskId": "task_789", + "contextId": "ctx_456" +} +``` + +**SSE status update:** +```json +{ + "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 +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 +{ + "status": "submitted", + "taskId": "task_abc", + "contextId": "ctx_456" +} +``` + +**Webhook POST (Task) when completed:** +```json +{ + "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 +{ + "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 +await a2a.send({ + contextId: 'ctx_456', // Continue the conversation + message: { + parts: [{ kind: 'text', text: 'Yes, I confirm the $500K budget' }] + } +}); +``` + +### Error (`failed`) + +**Response:** +```json +{ + "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.0.13/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.0.13/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 NEW creatives only. To update existing creatives, use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives). To assign existing library creatives, use `creative_assignments` instead. +- **Inline creative lifecycle**: Inline creatives enter the library with the same lifecycle as `sync_creatives` uploads. 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 can be reused by `creative_id` on a later `create_media_buy` call. Creative review is independent of the buy outcome; sellers MUST NOT skip review solely because the buy did not activate. Retention of unassigned creatives is seller-defined in 3.0. See [Inline creatives on the package](/dist/docs/3.0.13/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.0.13/governance/content-standards/tasks/create_content_standards) on a verification agent (e.g., IAS, DoubleVerify). Standards MUST be [calibrated](/dist/docs/3.0.13/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.0.13/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 +{ + "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. **Upload Creatives**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) before deadline +2. **Monitor Status**: Use [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) +3. **Optimize**: Use [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) +4. **Update**: Use [`update_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/update_media_buy) to modify campaign + +## Learn More + +- [Media Buy Lifecycle](/dist/docs/3.0.13/media-buy/media-buys/) - Complete campaign workflow +- [get_products](/dist/docs/3.0.13/media-buy/task-reference/get_products) - Discover inventory +- [Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting) - Targeting strategies +- [Pricing Models](/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models) - Currency and pricing diff --git a/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery.mdx b/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery.mdx new file mode 100644 index 0000000000..88f9555cf7 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery.mdx @@ -0,0 +1,790 @@ +--- +title: get_media_buy_delivery +description: "get_media_buy_delivery task — retrieve impressions, spend, pacing, and dimensional breakdowns for active AdCP campaigns. Supports custom date ranges and metric filtering." +"og:title": "AdCP — get_media_buy_delivery" +testable: true +--- + + +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.0.13/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.0.13/media-buy/get-media-buy-delivery-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buy-delivery-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/get-media-buy-delivery-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buy-delivery-response.json) + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `account` | [account-ref](/dist/docs/3.0.13/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) and `system` for metro/postal levels. Unsupported dimensions are silently omitted; malformed requests return a validation error. | + +> **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) | +| `media_buy_deliveries` | Array of delivery data per media buy | + +### Media Buy Delivery Object + +| 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`. | +| `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.0.13/media-buy/get-media-buy-delivery-response.json) for complete field list. + +### Billing-grade vs best-effort numbers + +Unless the seller explicitly declares otherwise, `get_media_buy_delivery` returns **best-effort** numbers: real-time or near-real-time telemetry, subject to revision as counts settle, **not** an invoicing source of truth. Callers that use these numbers for client reporting or pacing decisions are safe; callers that use them for reconciliation, accruals, or finance close are not. + +Sellers **MAY** declare a finalization window in their capabilities (e.g., `delivery_reporting.finalization_window_hours`) and, once that window has elapsed for a reporting period, treat the numbers returned for that period as authoritative for billing — stable across subsequent calls, suitable for invoice reconciliation, and liable as the source of truth in a dispute. Callers **MUST NOT** assume finalization in the absence of such a declaration. + +When a buyer's independent measurement disagrees with these numbers, reconciliation happens out-of-band through the commercial relationship between counterparties, backed by the audit trail on both sides. AdCP 3.0 does not specify a structured dispute task. + +## Common Scenarios + +### Single Media Buy + + + +```javascript JavaScript test=false +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuyDeliveryResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuyDeliveryResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuyDeliveryResponseSchema } from '@adcp/client'; + +// 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 +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 +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuyDeliveryResponseSchema } from '@adcp/client'; + +// 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 +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()) +``` + + + +### Buyer Reference Query + + + +```javascript JavaScript +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuyDeliveryResponseSchema } from '@adcp/client'; + +// Query by buyer reference instead of media buy ID +const result = await testAgent.getMediaBuyDelivery({ + media_buy_ids: ['acme_q1_campaign_2024', 'acme_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 +import asyncio +from adcp.testing import test_agent +from adcp.types import GetMediaBuyDeliveryRequest + +async def main(): + # Query by buyer reference instead of media buy ID + result = await test_agent.get_media_buy_delivery( + GetMediaBuyDeliveryRequest( + media_buy_ids=['acme_q1_campaign_2024', 'acme_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 +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuyDeliveryResponseSchema } from '@adcp/client'; + +// 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 +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) | +| **Reach Unit** | Unit of measurement for reach — required when reach is present | +| **Frequency** | Average ad exposures per reach unit | +| **Follows** | New followers, subscribes, or page likes 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.0.13/media-buy/advanced-topics/accountability) for measurement terms and [Optimization and reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) for the full lifecycle. + +## Error Handling + +| Error Code | Description | Resolution | +|------------|-------------|------------| +| `AUTH_REQUIRED` | Authentication needed | Provide credentials | +| `MEDIA_BUY_NOT_FOUND` | Media buy doesn't exist | Verify media_buy_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. | +| `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.) + +**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 +{ + "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.0.13/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.0.13/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 +{ + "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 + } + ] + } + ] +} +``` + +## 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 +{ + "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`) and `system` for metro/postal levels. 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 | Capability flag | +|-----------|----------------|-----------------|-----------------| +| Geography | `by_geo` | `geo_level`, `geo_code`, `impressions`, `spend` | `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` | `supports_audience_breakdown` | +| Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `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. + +### 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.0.13/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.0.13/governance/property/tasks/validate_property_delivery) to catch unauthorized supply paths + +| Governance task | Purpose | +|----------------|---------| +| [`report_plan_outcome`](/dist/docs/3.0.13/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.0.13/governance/property/tasks/validate_property_delivery) | Validate delivery records against property lists — catches ads running on unauthorized properties | +| [`validate_content_delivery`](/dist/docs/3.0.13/governance/content-standards/tasks/validate_content_delivery) | Validate content artifacts against brand suitability standards | +| [`get_plan_audit_logs`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/update_media_buy) to adjust budgets, pacing, or targeting +2. **Provide Feedback**: Use [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) to share results with seller +3. **Update Creatives**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) to refresh underperforming assets +4. **Create Follow-Up Campaigns**: Use [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) based on insights + +## Learn More + +- [Media Buy Lifecycle](/dist/docs/3.0.13/media-buy/media-buys/) - Complete campaign workflow +- [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) - Async patterns and status handling +- [Performance Optimization](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) - Using delivery data for optimization diff --git a/dist/docs/3.0.13/media-buy/task-reference/get_media_buys.mdx b/dist/docs/3.0.13/media-buy/task-reference/get_media_buys.mdx new file mode 100644 index 0000000000..4a539529eb --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/get_media_buys.mdx @@ -0,0 +1,412 @@ +--- +title: get_media_buys +description: "get_media_buys task — retrieve media buy status in AdCP including creative approvals, missing assets, configuration, and optional near-real-time delivery snapshots." +"og:title": "AdCP — get_media_buys" +testable: false +--- + + +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.0.13/media-buy/specification#account-ownership-vs-creation-surface). + +**Request Schema**: [`/schemas/3.0.13/media-buy/get-media-buys-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buys-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/get-media-buys-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-media-buys-response.json) + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `account` | [account-ref](/dist/docs/3.0.13/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. | +| `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 + +| 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`) | +| `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 confirmed this media buy | +| `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. | +| `packages` | Array of packages with creative status and optional snapshots | + +### Package Object + +| Field | Description | +|-------|-------------| +| `package_id` | Seller's package identifier | +| `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`) | +| `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. | +| `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`) | + +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 assets via [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives). + +### 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. + +### 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`. + +## 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` | `cancel`, `sync_creatives` | +| `pending_start` | `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). + +## Common Scenarios + +### Check creative approval status + + + +```javascript JavaScript +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuysResponseSchema } from '@adcp/client'; + +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 +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 +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuysResponseSchema } from '@adcp/client'; + +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 +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 +import { testAgent } from '@adcp/client/testing'; +import { GetMediaBuysResponseSchema } from '@adcp/client'; + +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 +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_REQUIRED` | Authentication needed | Provide credentials | + +## Next Steps + +- **Upload missing creatives**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) for formats in `format_ids_pending` +- **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.0.13/media-buy/task-reference/update_media_buy) to adjust pricing or targeting +- **Detailed reporting**: Use [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) for date-range reporting and daily breakdowns +- **Optimize campaigns**: Use [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) to share results with the seller diff --git a/dist/docs/3.0.13/media-buy/task-reference/get_products.mdx b/dist/docs/3.0.13/media-buy/task-reference/get_products.mdx new file mode 100644 index 0000000000..cc8a034550 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/get_products.mdx @@ -0,0 +1,1363 @@ +--- +title: get_products +description: "get_products task — discover advertising inventory in AdCP using natural language campaign briefs or structured filters. Returns matched products with pricing and formats." +"og:title": "AdCP — get_products" +testable: true +--- + +Discover available advertising products based on campaign requirements using natural language briefs or structured filters. + +**Authentication**: Optional (returns limited results without credentials) + +**Response Time**: ~60 seconds (AI inference with back-end systems) + +**Request Schema**: [`/schemas/3.0.13/media-buy/get-products-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-products-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/get-products-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/get-products-response.json) + +## Quick Start + +Discover products with a natural language brief: + + + +```javascript JavaScript +import { testAgent } from '@adcp/client/testing'; +import { GetProductsResponseSchema } from '@adcp/client'; + +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 +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 +uvx adcp \ + https://test-agent.adcontextprotocol.org/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 +import { testAgent } from '@adcp/client/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 +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 inventory 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"`. | +| `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.0.13/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) | +| `property_list` | PropertyListRef | No | [AdCP 3.0] Reference to a property list for filtering. See [Property Lists](/dist/docs/3.0.13/governance/property/tasks/property_lists) | +| `pagination` | PaginationRequest | No | Cursor-based pagination for large product catalogs (see below) | +| `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"}`. | + + +**Property Governance** + +The `property_list` filter references a property list created via [`create_property_list`](/dist/docs/3.0.13/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.0.13/protocol/get_adcp_capabilities) to support this filter. See the [Property Governance overview](/dist/docs/3.0.13/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 | +| `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.0.13/reference/media-channel-taxonomy) | +| `postal_areas` | object[] | Filter by postal area coverage. Each entry: `{ system, values }` (e.g., `[{ "system": "us_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 | +| `required_performance_standards` | [PerformanceStandard[]](/dist/docs/3.0.13/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. | + +### 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.0.13/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.0.13/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. | +| `brief_relevance` | string | Why this product matches the brief (when brief provided) | +| `measurement_readiness` | [MeasurementReadiness](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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. | + +### Proposals Array (Optional) + +Publishers may return proposals alongside products - structured media plans with budget allocations. See [Proposals](/dist/docs/3.0.13/media-buy/product-discovery/media-products#proposals) for details. + +| Field | Type | Description | +|-------|------|-------------| +| `proposal_id` | string | Unique identifier for executing this proposal via `create_media_buy` | +| `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.0.13/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 | + +### Pagination + +For large product catalogs, use cursor-based pagination: + +| 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 matching products (optional, not all backends support this) | + +Pagination is optional. When omitted, the server returns all results (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. + +### 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. | + +### 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. | +| `description` | string | Yes | Human-readable explanation of what is missing and why. | +| `estimated_wait` | Duration | No | How much additional time would resolve this scope. | + +**See schema for complete field list**: [`get-products-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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 +import { testAgent } from '@adcp/client/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 +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 +{ + "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" } + } + ] +} +``` + +### Standard Catalog Discovery + + + +```javascript JavaScript +import { testAgent } from '@adcp/client/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 catalog products`); +} +``` + +```python Python +import asyncio +from adcp.testing import test_agent + +async def discover_standard_catalog(): + # 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 catalog products") + +asyncio.run(discover_standard_catalog()) +``` + + + +### Multi-Format Discovery + + + +```javascript JavaScript +import { testAgent } from '@adcp/client/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 +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 +import { testAgent } from '@adcp/client/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 +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 +import { testAgent } from '@adcp/client/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 +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 +import { testAgent } from '@adcp/client/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 +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 +import { testAgent } from '@adcp/client/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 +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 +import { testAgent } from '@adcp/client/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 +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 +uvx adcp \ + https://test-agent.adcontextprotocol.org/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 +{ + "catalog": { + "type": "product", + "gtins": ["00013000006040", "00013000006057"] + } +} +``` + +```json +{ + "catalog": { + "catalog_id": "gmc-primary", + "type": "product" + } +} +``` + +```json +{ + "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 +import { testAgent } from '@adcp/client/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 +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.0.13/media-buy/product-discovery/media-products#property-targeting) for more details and [Property Governance](/dist/docs/3.0.13/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.0.13/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 +{ + "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 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_REQUIRED` | Authentication needed for full catalog | Provide credentials via auth header | +| `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 | +| `POLICY_VIOLATION` | Category blocked for advertiser | See policy response message for details | + +### Authentication Comparison + +See the difference between authenticated and unauthenticated access: + + + +```javascript JavaScript +import { testAgent, testAgentNoAuth } from '@adcp/client/testing'; + +// WITH authentication - full catalog with pricing +const fullCatalog = await testAgent.getProducts({ + buying_mode: 'brief', + brief: 'Premium CTV inventory for brand awareness', + brand: { + domain: 'acmecorp.com' + } +}); + +if (!fullCatalog.success) { + throw new Error(`Failed to get products: ${fullCatalog.error}`); +} + +console.log(`With auth: ${fullCatalog.data.products.length} products`); +console.log(`First product pricing: ${fullCatalog.data.products[0].pricing_options.length} options`); + +// WITHOUT authentication - limited public catalog +const publicCatalog = await testAgentNoAuth.getProducts({ + buying_mode: 'brief', + brief: 'Premium CTV inventory for brand awareness', + brand: { + domain: 'acmecorp.com' + } +}); + +if (!publicCatalog.success) { + throw new Error(`Failed to get products: ${publicCatalog.error}`); +} + +console.log(`Without auth: ${publicCatalog.data.products.length} products`); +console.log(`First product pricing: ${publicCatalog.data.products[0].pricing_options?.length || 0} options`); +``` + +```python Python +import asyncio +from adcp.testing import test_agent, test_agent_no_auth + +async def compare_auth(): + # WITH authentication - full catalog with pricing + full_catalog = 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_catalog['products'])} products") + print(f"First product pricing: {len(full_catalog['products'][0]['pricing_options'])} options") + + # WITHOUT authentication - limited public catalog + public_catalog = 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_catalog['products'])} products") + print(f"First product pricing: {len(public_catalog['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 catalog (standard catalog products), no pricing, no custom offerings +- **With credentials**: Returns complete catalog with pricing and custom products + +See [Authentication Guide](/dist/docs/3.0.13/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` and can track progress through webhooks or polling. + +### 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 +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 +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 +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) + +For searches requiring deep inventory analysis, configure a webhook: + +```json +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" }, + "pushNotificationConfig": { + "url": "https://buyer.com/webhooks/adcp/get_products", + "authentication": { + "schemes": ["Bearer"], + "credentials": "secret_token_32_chars" + } + } + } +} + +Response (200 OK): +{ + "status": "working", + "message": "Searching premium inventory across display, video, and audio", + "task_id": "task_456", + "context_id": "ctx_123", + "percentage": 10, + "current_step": "searching_inventory" +} + +// Later, 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 +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 +// 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) + +Configure push notifications for long searches: + +```json +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" } + } + } + }] + }, + "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": "working", + "message": { + "role": "agent", + "parts": [ + { "text": "Searching premium inventory across display, video, and audio" }, + { + "data": { + "percentage": 10, + "current_step": "searching_inventory" + } + } + ] + } + } +} + +// Later, 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 for webhook or poll for updates | +| `submitted` | Custom curation queued | Wait for webhook notification | +| `failed` | Search couldn't complete | Check error message, adjust brief | + +**Note:** For the complete status list see [Task Lifecycle](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/create_media_buy) to execute campaign +3. **Prepare Creatives**: Use [`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) to see format requirements +4. **Upload Assets**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) to provide creative assets + +## Learn More + +- [Product Discovery Guide](/dist/docs/3.0.13/media-buy/product-discovery/) - Understanding briefs and products +- [Pricing Models](/dist/docs/3.0.13/media-buy/advanced-topics/pricing-models) - CPM, CPCV, CPP explained +- [Brief Expectations](/dist/docs/3.0.13/media-buy/product-discovery/brief-expectations) - How to write effective briefs +- [Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products) - Product structure and fields diff --git a/dist/docs/3.0.13/media-buy/task-reference/index.mdx b/dist/docs/3.0.13/media-buy/task-reference/index.mdx new file mode 100644 index 0000000000..ce33a00d0c --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/index.mdx @@ -0,0 +1,165 @@ +--- +title: Task Reference +sidebarTitle: Overview +description: "AdCP media buy task reference — all tasks for product discovery, campaign creation, delivery reporting, creatives, audiences, and conversion tracking with schemas and examples." +"og:title": "AdCP — Media buy task reference" +--- + + +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.0.13/media-buy/task-reference/get_products) | Discover inventory and refine products | ~60s | Discovery | +| [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) | Create campaigns from selected products | Minutes-Days | Media Buys | +| [`update_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/update_media_buy) | Modify campaign settings and budgets | Minutes-Days | Media Buys | +| [`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) | View supported creative specifications | ~1s | Capability | +| [`sync_catalogs`](/dist/docs/3.0.13/media-buy/task-reference/sync_catalogs) | Sync catalog feeds (products, stores, inventory) | Minutes-Days | Catalogs | +| [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) | Upload and manage creative assets | Minutes-Days | Creatives | +| [`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives) | Query creative library with filtering | ~1s | Creatives | +| [`get_media_buys`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/get_media_buy_delivery) | Retrieve performance and delivery data | ~60s | Reporting | +| [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) | Submit performance signals for optimization | ~1s | Optimization | +| [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) | Configure event sources for conversion tracking | ~1s | Conversion Tracking | +| [`log_event`](/dist/docs/3.0.13/media-buy/task-reference/log_event) | Send marketing events for attribution | ~1s | Conversion Tracking | +| [`sync_audiences`](/dist/docs/3.0.13/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.0.13/creative/task-reference/list_creative_formats) - Format specifications +- [`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives) - Creative library queries +- [`get_media_buys`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buys) - Media buy status and creative approvals +- [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) - Performance signal submission +- [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) - Event source configuration +- [`log_event`](/dist/docs/3.0.13/media-buy/task-reference/log_event) - Event ingestion + +### 🟡 Processing (~60 seconds) +**AI/LLM inference with backend systems** +- [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) - Natural language product discovery and refinement +- [`get_media_buy_delivery`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/create_media_buy) - Campaign creation and validation +- [`update_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/update_media_buy) - Campaign modifications +- [`sync_catalogs`](/dist/docs/3.0.13/media-buy/task-reference/sync_catalogs) - Catalog feed processing and review +- [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) - Creative asset processing +- [`sync_audiences`](/dist/docs/3.0.13/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.0.13/accounts/tasks/sync_accounts)** - Declare brand/operator pairs and billing; seller provisions accounts +- **[`list_accounts`](/dist/docs/3.0.13/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.0.13/protocol/get_adcp_capabilities)** - Discover agent capabilities, portfolio, and supported features (protocol-level task) +- **[`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products)** - The core discovery task using natural language briefs +- **[`list_creative_formats`](/dist/docs/3.0.13/creative/task-reference/list_creative_formats)** - Understand creative requirements + +### Media Buy Management +Create and manage your advertising campaigns. + +- **[`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy)** - Create campaigns from discovered products +- **[`update_media_buy`](/dist/docs/3.0.13/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.0.13/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.0.13/creative/task-reference/sync_creatives)** - Upload assets to an agent-hosted creative library +- **[`list_creatives`](/dist/docs/3.0.13/creative/task-reference/list_creatives)** - Search and manage your creative library (creative protocol) + +### Performance & Optimization +Monitor and optimize campaign performance. + +- **[`get_media_buys`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/get_media_buy_delivery)** - Track delivery and performance metrics for reporting +- **[`provide_performance_feedback`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/sync_event_sources)** - Configure event sources on seller accounts +- **[`log_event`](/dist/docs/3.0.13/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.0.13/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.0.13/media-buy/[task-name]-request.json` +- **Response Schemas**: `/schemas/3.0.13/media-buy/[task-name]-response.json` + +**Task Management**: For tracking async operations across all AdCP domains, see [Task Lifecycle](/dist/docs/3.0.13/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.0.13/protocol/get_adcp_capabilities) to understand what the agent supports +2. **Find Inventory**: Use [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) to find relevant inventory +3. **Refine Products**: Re-call [`get_products`](/dist/docs/3.0.13/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.0.13/creative/task-reference/list_creative_formats) for requirements +5. **Sync Catalogs**: Use [`sync_catalogs`](/dist/docs/3.0.13/media-buy/task-reference/sync_catalogs) to push product feeds to the account +6. **Upload Creatives**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) for asset management +7. **Create Campaign**: Use [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) with selected products +8. **Check Operational State**: Use [`get_media_buys`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/get_media_buy_delivery) + +For reporting and reconciliation, treat [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) as the authoritative, billing-grade source. Use [`get_media_buys`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buys) snapshots for operational monitoring only. + +## Related Documentation + +- **[Product Discovery](/dist/docs/3.0.13/media-buy/product-discovery/)** - Natural language inventory discovery +- **[Media Buys](/dist/docs/3.0.13/media-buy/media-buys/)** - Campaign lifecycle management +- **[Creatives](/dist/docs/3.0.13/media-buy/creatives/)** - Creative asset management +- **[Advanced Topics](/dist/docs/3.0.13/media-buy/advanced-topics/)** - Targeting, security, and architecture diff --git a/dist/docs/3.0.13/media-buy/task-reference/log_event.mdx b/dist/docs/3.0.13/media-buy/task-reference/log_event.mdx new file mode 100644 index 0000000000..4e53ee70cf --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/log_event.mdx @@ -0,0 +1,532 @@ +--- +title: log_event +description: "log_event task — send conversion and marketing events to AdCP sellers in batches. Supports attribution, campaign optimization, ROAS measurement, and test events." +"og:title": "AdCP — log_event" +testable: false +--- + + +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.0.13/media-buy/log-event-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/log-event-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/log-event-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/log-event-response.json) + +## Quick Start + +Log a purchase event: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { LogEventResponseSchema } from "@adcp/client"; + +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 +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.0.13/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.0.13/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.0.13/media-buy/conversion-tracking/#user-match) | No | User identifiers for attribution matching | +| `custom_data` | [CustomData](/dist/docs/3.0.13/media-buy/conversion-tracking/#custom-data) | No | Event-specific data (value, currency, items) | +| `action_source` | [ActionSource](/dist/docs/3.0.13/media-buy/conversion-tracking/#action-sources) | No | Where the event occurred (`website`, `app`, `in_store`, etc.) | +| `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.0.13/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 | +| `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 + +### Batch Events + +Send multiple events in a single request: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { LogEventResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { LogEventResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { LogEventResponseSchema } from "@adcp/client"; + +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 +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.0.13/media-buy/conversion-tracking/) - Data model, optimization goals, and the end-to-end flow +- [sync_event_sources](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) - Configure event sources before logging events +- [create_media_buy](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy#campaign-with-conversion-optimization) - Set optimization goals on packages +- [get_media_buy_delivery](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) - Monitor conversion metrics in delivery reports diff --git a/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback.mdx b/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback.mdx new file mode 100644 index 0000000000..ab202352e2 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback.mdx @@ -0,0 +1,396 @@ +--- +title: provide_performance_feedback +description: "provide_performance_feedback task — share normalized performance scores with AdCP publishers so sellers can optimize delivery based on buyer-observed outcomes." +"og:title": "AdCP — provide_performance_feedback" +--- + + +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.0.13/media-buy/provide-performance-feedback-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/provide-performance-feedback-request.json) +**Response Schema**: [`https://adcontextprotocol.org/schemas/3.0.13/media-buy/provide-performance-feedback-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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 | No | The business metric being measured (defaults to "overall_performance") | +| `feedback_source` | string | No | Source of the performance data (defaults to "buyer_attribution") | + +## 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 +{ + "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 +{ + "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 +{ + "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 +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 +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 +{ + "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 +{ + "$schema": "/schemas/3.0.13/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" +} +``` + +#### 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 +{ + "$schema": "/schemas/3.0.13/media-buy/provide-performance-feedback-response.json", + "success": true, + "message": "Performance feedback processed successfully. Optimization algorithms updated." +} +``` + +### Example 2: Package-Specific Performance Feedback + +#### Request +```json +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/provide-performance-feedback-response.json", + "success": true, + "message": "Exceptional performance noted. Increasing allocation to similar segments." +} +``` + +### Example 3: Creative-Specific Performance Feedback + +#### Request +```json +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/media-buy/provide-performance-feedback-response.json", + "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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "$schema": "/schemas/3.0.13/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 +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 +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.0.13/media-buy/task-reference/get_media_buy_delivery) - Retrieve delivery metrics +- [Optimization & Reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) - Performance feedback concepts +- [Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting) - Understanding targeting for optimization \ No newline at end of file diff --git a/dist/docs/3.0.13/media-buy/task-reference/sync_audiences.mdx b/dist/docs/3.0.13/media-buy/task-reference/sync_audiences.mdx new file mode 100644 index 0000000000..48a54b0e15 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/sync_audiences.mdx @@ -0,0 +1,550 @@ +--- +title: sync_audiences +description: "sync_audiences task — upload hashed first-party CRM audiences to AdCP seller accounts for retargeting, suppression, and lookalike expansion. Supports match status tracking." +"og:title": "AdCP — sync_audiences" +testable: false +--- + + +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.0.13/signals/): signals are third-party data products you discover and activate; 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. 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. + +**Request Schema**: [`/schemas/3.0.13/media-buy/sync-audiences-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-audiences-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/sync-audiences-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-audiences-response.json) + +## Quick Start + +Upload a customer list and check its status: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncAudiencesResponseSchema } from "@adcp/client"; +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); + +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} (${audience.status ?? "n/a"})`); + if (audience.status === "ready") { + console.log(` Matched ${audience.matched_count} of ${audience.uploaded_count} members (this sync)`); + } + } +} +``` + +```python Python +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')}, + ] + }] + ) + + 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}: {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.0.13/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 + +**Success Response:** + +- `audiences` — Results for each audience on the account, including audiences not in this request + +**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 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 +{ + "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 +import { testAgent } from "@adcp/client/testing"; +import { SyncAudiencesResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { SyncAudiencesResponseSchema } from "@adcp/client"; +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { SyncAudiencesResponseSchema } from "@adcp/client"; +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { SyncAudiencesResponseSchema } from "@adcp/client"; + +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 +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 +{ + "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`. + +## 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 +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.0.13/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.0.13/media-buy/advanced-topics/targeting) — Reference audiences in `targeting_overlay.audience_include` and `audience_exclude` +- [create_media_buy](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) — Apply audience targeting to packages +- [Conversion Tracking](/dist/docs/3.0.13/media-buy/conversion-tracking/) — Track outcomes from audience-targeted campaigns diff --git a/dist/docs/3.0.13/media-buy/task-reference/sync_catalogs.mdx b/dist/docs/3.0.13/media-buy/task-reference/sync_catalogs.mdx new file mode 100644 index 0000000000..3cb0529685 --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/sync_catalogs.mdx @@ -0,0 +1,266 @@ +--- +title: sync_catalogs +description: "sync_catalogs task — sync product feeds, store locations, and vertical catalogs (hotel, flight, vehicle, real estate) to AdCP seller accounts for catalog-driven campaigns." +"og:title": "AdCP — sync_catalogs" +--- + + +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. + +**Response Time**: Instant to days (returns `completed` for small catalogs, or `submitted` for large feeds requiring platform review) + +**Request Schema**: [`/schemas/3.0.13/media-buy/sync-catalogs-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-catalogs-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/sync-catalogs-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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 +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.0.13/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 +{ + "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.0.13/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.0.13/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 +{ + "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 +{ + "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 +{ + "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 +{ + "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 +{ + "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.0.13/creative/catalogs) — Complete documentation on catalog types, sourcing, and format requirements +- [Account state](/dist/docs/3.0.13/building/by-layer/L2/account-state) — How catalogs fit into the account setup sequence +- [sync_creatives](/dist/docs/3.0.13/creative/task-reference/sync_creatives) — Submit creatives that reference synced catalogs +- [list_creative_formats](/dist/docs/3.0.13/creative/task-reference/list_creative_formats) — Discover format catalog requirements diff --git a/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources.mdx b/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources.mdx new file mode 100644 index 0000000000..3ab8f8dccd --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources.mdx @@ -0,0 +1,450 @@ +--- +title: sync_event_sources +description: "sync_event_sources task — configure website pixels, mobile SDKs, and server-to-server event sources on AdCP seller accounts for conversion tracking and attribution." +"og:title": "AdCP — sync_event_sources" +testable: false +--- + + +Configure event sources on a seller account for conversion tracking. Supports upsert semantics, seller-managed sources, and setup instructions. + +**Response Time**: ~1s (synchronous configuration) + +**Request Schema**: [`/schemas/3.0.13/media-buy/sync-event-sources-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-event-sources-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/sync-event-sources-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/sync-event-sources-response.json) + +## Quick Start + +Configure an event source for purchase tracking: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncEventSourcesResponseSchema } from "@adcp/client"; + +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 +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.0.13/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.0.13/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.0.13/media-buy/conversion-tracking/#event-types)[] | No | Event types this source handles. If omitted, accepts all event types. | +| `allowed_domains` | string[] | No | Domains authorized to send events for this source | + +## 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.) +- `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"`) + +**See schema for complete field list**: [sync-event-sources-response.json](https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +import { testAgent } from "@adcp/client/testing"; +import { SyncEventSourcesResponseSchema } from "@adcp/client"; + +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 +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 +import { testAgent } from "@adcp/client/testing"; +import { SyncEventSourcesResponseSchema } from "@adcp/client"; + +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 +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 +{ + "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. + +### Clean Sync with delete_missing + +Replace all buyer-managed event sources on the account: + + + +```javascript JavaScript +import { testAgent } from "@adcp/client/testing"; +import { SyncEventSourcesResponseSchema } from "@adcp/client"; + +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 +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.0.13/media-buy/conversion-tracking/) - Data model, optimization goals, and the end-to-end flow +- [log_event](/dist/docs/3.0.13/media-buy/task-reference/log_event) - Send marketing events to configured event sources +- [create_media_buy](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/get_media_buy_delivery) - Monitor conversion metrics in delivery reports diff --git a/dist/docs/3.0.13/media-buy/task-reference/update_media_buy.mdx b/dist/docs/3.0.13/media-buy/task-reference/update_media_buy.mdx new file mode 100644 index 0000000000..d14b68e3bb --- /dev/null +++ b/dist/docs/3.0.13/media-buy/task-reference/update_media_buy.mdx @@ -0,0 +1,868 @@ +--- +title: update_media_buy +description: "update_media_buy task — modify active AdCP campaigns with PATCH semantics. Update budgets, flight dates, targeting, status, and package-level settings." +"og:title": "AdCP — update_media_buy" +testable: true +--- + + +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.0.13/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` 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.0.13/media-buy/specification#account-ownership-vs-creation-surface) rule. + +**PATCH Semantics**: Only specified fields are updated; omitted fields remain unchanged. + +**Request Schema**: [`/schemas/3.0.13/media-buy/update-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/update-media-buy-request.json) +**Response Schema**: [`/schemas/3.0.13/media-buy/update-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.0.13/media-buy/update-media-buy-response.json) + +## Quick Start + +Create a media buy, then pause it: + + + +```javascript JavaScript test=false +import { testAgent } from '@adcp/client/testing'; +import { CreateMediaBuyResponseSchema, UpdateMediaBuyResponseSchema } from '@adcp/client'; + +// 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, + 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 +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, + 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.0.13/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.0.13/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. + +### 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 | +| `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 creatives with optional weights and placement targeting | +| `creatives` | CreativeAsset[] | Upload and assign new creatives inline (must not exist in library) | + +`package_id` is required to identify the package to update. + +## Response + +### Success Response + +| Field | Description | +|-------|-------------| +| `media_buy_id` | Media buy identifier | +| `status` | Media buy status after the update (present when status changes, e.g., cancellation) | +| `revision` | Revision number after this update. Use in subsequent requests for optimistic concurrency. | +| `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` | Actions the buyer can perform after this update. Saves a round-trip to `get_media_buys`. | +| `affected_packages` | Array of full Package objects showing complete state after update | + +### 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 +import { testAgent } from '@adcp/client/testing'; +import { UpdateMediaBuyResponseSchema } from '@adcp/client'; + +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 +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 +import { testAgent } from '@adcp/client/testing'; +import { UpdateMediaBuyResponseSchema } from '@adcp/client'; + +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 +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 +import { testAgent } from '@adcp/client/testing'; +import { UpdateMediaBuyResponseSchema } from '@adcp/client'; + +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 +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 +import { testAgent } from '@adcp/client/testing'; +import { UpdateMediaBuyResponseSchema } from '@adcp/client'; + +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 +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 +import { testAgent } from '@adcp/client/testing'; +import { UpdateMediaBuyResponseSchema } from '@adcp/client'; + +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 +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 +{ + "account": { "account_id": "acc_acme_001" }, + "media_buy_id": "mb_12345", + "canceled": true, + "cancellation_reason": "Campaign strategy changed" +} +``` + +**Success response:** +```json +{ + "media_buy_id": "mb_12345", + "status": "canceled", + "revision": 4, + "implementation_date": "2025-06-15T10:00:00Z", + "affected_packages": [] +} +``` + +**`NOT_CANCELLABLE` error response:** +```json +{ + "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 +{ + "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 +{ + "errors": [{ + "code": "REQUOTE_REQUIRED", + "message": "Doubling budget and extending end_time into Q4 changes the pricing basis of proposal prop_abc123", + "details": { + "proposal_id": "prop_abc123", + "envelope_field": ["packages[0].budget", "end_time"] + }, + "suggestion": "Call get_products with buying_mode='refine' against proposal_id prop_abc123 to obtain a fresh quote, then resubmit this update against the new proposal_id" + }] +} +``` + +### Cancel a Package + +Cancel a single package while the media buy remains active: + +```json +{ + "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:** +- Package ID +- Product ID +- Pricing option ID +- Format IDs (creatives must match existing formats) + +## Error Handling + +Common errors and resolutions: + +| Error Code | Description | Resolution | +|------------|-------------|------------| +| `MEDIA_BUY_NOT_FOUND` | Media buy doesn't exist | Verify media_buy_id | +| `PACKAGE_NOT_FOUND` | Package doesn't exist | Verify package_id | +| `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 change submitted past the package's `creative_deadline` | Check package `creative_deadline` before submitting creative changes | +| `CREATIVE_ID_EXISTS` | Creative ID already exists in library | Use a different `creative_id`, assign existing creatives via `creative_assignments`, or update via `sync_creatives` | +| `BUDGET_EXCEEDED` | Operation would exceed allocated budget | Reduce the amount or increase media buy total budget | +| `CONFLICT` | Revision mismatch — another update was applied since you last read | Re-read via `get_media_buys` and retry with current `revision` | +| `REQUOTE_REQUIRED` | Requested change (budget, dates, volume, targeting) falls outside the envelope the original quote was priced against | Call `get_products` with `buying_mode: "refine"` against the existing `proposal_id` to obtain a fresh quote, then resubmit the update against the new `proposal_id`. Seller's `error.details.envelope_field` names which fields breached. | +| `VALIDATION_ERROR` | Request format or business rule violation | Check error `field` and `message` for specifics | + +Example error response: + +```json +{ + "errors": [{ + "code": "UNSUPPORTED_FEATURE", + "message": "Cannot change product_id for existing package", + "field": "packages[0].product_id", + "suggestion": "Create a new package with the desired product instead" + }] +} +``` + +## Update Approval + +Some updates require seller approval and return pending status: + +- **Significant budget increases** (threshold varies by seller) +- **Date range changes** affecting inventory availability +- **Targeting changes** that alter campaign scope +- **Creative changes** requiring policy review + +When approval is needed, `implementation_date` will be `null` and `affected_packages` contains the proposed state of each package that would be modified: + +```json +{ + "media_buy_id": "mb_12345", + "implementation_date": null, + "affected_packages": [ + { + "package_id": "pkg_abc123", + "budget": 50000, + "status": "pending_start" + } + ] +} +``` + +## PATCH Semantics + +Only specified fields are updated - omitted fields remain unchanged: + +```json +{ + "account": { "account_id": "acc_acme_001" }, + "media_buy_id": "mb_12345", + "packages": [{ + "package_id": "pkg_001", + "budget": 50000 + }] +} +``` + +**Array replacement**: When updating arrays (like `creative_assignments`), provide the complete new array: + +```json +{ + "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 } + ] + }] +} +``` + +## Asynchronous Operations + +Updates may be asynchronous, especially with seller approval. + +### Response Patterns + +**Synchronous (completed immediately)** — campaign-level update (e.g., `paused: true`): +```json +{ + "media_buy_id": "mb_12345", + "implementation_date": "2025-06-15T10:00:00Z", + "affected_packages": [] +} +``` + +**Synchronous (completed immediately)** — package-level update: +```json +{ + "media_buy_id": "mb_12345", + "implementation_date": "2025-06-15T10:00:00Z", + "affected_packages": [ + { + "package_id": "pkg_abc123", + "budget": 50000, + "status": "active" + } + ] +} +``` + +**Asynchronous (processing)**: +```json +{ + "status": "working", + "message": "Processing update..." +} +``` +Poll for completion or use webhooks/streaming. + +**Manual Approval Required**: +```json +{ + "status": "submitted", + "message": "Update requires seller approval (2-4 hours)" +} +``` +Will take hours to days. + +### Protocol-Specific Handling + +AdCP tasks work across multiple protocols (MCP, A2A, REST). Each protocol handles async operations differently: + +- **Status checking**: Polling, webhooks, or streaming +- **Updates**: Protocol-specific mechanisms +- **Long-running tasks**: Different timeout and notification patterns + +See [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) for protocol-specific async patterns and examples. + +## Best Practices + +**1. Use Precise Updates** +Update only what needs to change - don't resend unchanged values. + +**2. Budget Increases** +Small incremental increases are more likely to be auto-approved than large jumps. + +**3. Pause Before Major Changes** +Pause campaigns before making significant targeting or creative changes to avoid delivery issues. + +**4. Test with Small Changes** +Test update workflows with minor changes before critical campaign modifications. + +**5. Monitor Status** +Always check response status and `implementation_date` for approval requirements. + +**6. Validate Package State** +Check `affected_packages` in response to confirm changes were applied correctly. + +## Usage Notes + +- Updates are atomic - either all changes apply or none do +- Both media buys and packages can be referenced by publisher IDs +- Pending states (`working`, `submitted`) are normal, not errors +- Orchestrators MUST handle pending states as part of normal workflow +- `implementation_date` indicates when changes take effect (null if pending approval) +- **Inline creatives**: The `creatives` array creates NEW creatives only. To update existing creatives, use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives). To assign existing library creatives, use `creative_assignments` in the Package Update object. + + +**Campaign Governance — Modification Phase** + +When a buyer's account has governance agents configured, sellers MUST call [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance) with `media_buy_id`, `planned_delivery`, and `phase: "modification"` before confirming an update. The governance agent validates change magnitude, budget reallocation, and new parameters against the campaign plan. + +See the [seller integration guide](/dist/docs/3.0.13/building/operating/seller-integration#execution-checks) for the full execution check workflow and code example. + + +## Next Steps + +After updating a media buy: + +1. **Verify Changes**: Use [`get_media_buy_delivery`](/dist/docs/3.0.13/media-buy/task-reference/get_media_buy_delivery) to confirm updates +2. **Upload New Creatives**: Use [`sync_creatives`](/dist/docs/3.0.13/creative/task-reference/sync_creatives) if creative assignments changed +3. **Monitor Performance**: Track impact of changes on campaign metrics +4. **Optimize Further**: Use [`provide_performance_feedback`](/dist/docs/3.0.13/media-buy/task-reference/provide_performance_feedback) for ongoing optimization + +## Learn More + +- [Media Buy Lifecycle](/dist/docs/3.0.13/media-buy/media-buys/) - Complete campaign workflow +- [Targeting](/dist/docs/3.0.13/media-buy/advanced-topics/targeting) - Targeting overlays and restrictions +- [Async Operations](/dist/docs/3.0.13/building/by-layer/L3/async-operations) - Async patterns and status checking +- [create_media_buy](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy) - Initial campaign creation diff --git a/dist/docs/3.0.13/protocol/architecture.mdx b/dist/docs/3.0.13/protocol/architecture.mdx new file mode 100644 index 0000000000..03b213cf04 --- /dev/null +++ b/dist/docs/3.0.13/protocol/architecture.mdx @@ -0,0 +1,122 @@ +--- +title: Protocol architecture +sidebarTitle: Architecture +"og:image": /images/concepts/protocol-domain-map.png +description: "AdCP protocol architecture: identity layer (brand, registry, accounts), transaction domains (media buy, creative, signals, sponsored intelligence), and cross-cutting governance." +"og:title": "AdCP — Protocol architecture" +--- + +# 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 buy-side identity through `brand.json` files hosted at `/.well-known/brand.json`. Brands declare their corporate hierarchy, sub-brands, properties, and authorized operators. Any domain can be resolved to a canonical brand identity. See [Brand Protocol](/dist/docs/3.0.13/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.0.13/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.0.13/accounts/overview). + +### Transaction domains + +Four protocol domains handle core advertising operations. The [Trusted Match Protocol (TMP)](/dist/docs/3.0.13/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.0.13/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.0.13/creative). + +**Signals** enables audience and targeting data discovery (`get_signals`) and activation (`activate_signal`). Data providers publish signal catalogs that buyers can discover with natural language queries, then activate on decisioning platforms. See [Signals](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/trusted-match). + +**Agentic eXecution Engine (AXE)** — deprecated predecessor to TMP. See [AXE documentation](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/verification/validate-your-agent#verifying-cross-instance-state). diff --git a/dist/docs/3.0.13/protocol/calling-an-agent.mdx b/dist/docs/3.0.13/protocol/calling-an-agent.mdx new file mode 100644 index 0000000000..ac922fc187 --- /dev/null +++ b/dist/docs/3.0.13/protocol/calling-an-agent.mdx @@ -0,0 +1,135 @@ +--- +title: Calling an AdCP agent +sidebarTitle: Calling an agent +description: "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[]." +"og:title": "AdCP — Calling an agent" +--- + +# 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.0.13/building/by-layer/L0/schemas) 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.0.13/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/client` 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 body** → server-defined; most agents return the original cached response and ignore the body change. Don't rely on it. + +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 +// variant 0: by seller-assigned id (from sync_accounts or list_accounts) +"account": { "account_id": "seller_assigned_id" } + +// variant 1: by natural key (brand + operator, optional sandbox) +"account": { "brand": { "domain": "acme.com" }, "operator": "sales.example" } +``` + +**Do NOT merge required fields across variants.** `additionalProperties: false` on each variant means `{account_id, brand}` fails BOTH. + +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 +// 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": "..." }] } +``` + +When you see `status: 'submitted'`, the work is **not** complete. Poll via `tasks/get` (A2A) or the MCP async task extension, using the returned `task_id`. Over A2A the AdCP `task_id` also rides on `artifact.metadata.adcp_task_id`. + +## Error recovery — read `issues[]` + +Every validation failure produces an envelope shaped like: + +```json +{ + "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 via `tasks/get` (A2A) or the MCP async task extension using `task_id`. | + +## 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.0.13/protocol/architecture) — how the protocol domains fit together. +- [Required tasks](/dist/docs/3.0.13/protocol/required-tasks) — which tasks an agent must implement to claim a specialism. +- [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities) — first call against any new agent. +- [Schemas and SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas) — how SDKs consume the protocol tarball (which now bundles `skills/`). diff --git a/dist/docs/3.0.13/protocol/capabilities-explorer.mdx b/dist/docs/3.0.13/protocol/capabilities-explorer.mdx new file mode 100644 index 0000000000..941dcd20e0 --- /dev/null +++ b/dist/docs/3.0.13/protocol/capabilities-explorer.mdx @@ -0,0 +1,245 @@ +--- +title: Capabilities explorer +sidebarTitle: Capabilities explorer +description: "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." +"og:title": "AdCP — Capabilities explorer" +--- + +# Capabilities explorer + +This page renders the actual top-level shape of the [`get_adcp_capabilities`](/dist/docs/3.0.13/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.0.13/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-beta"`). 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.0.13/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. Currently `catalog_signals` (structured signal_id references). **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.0.13/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.0.13/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.0.13/protocol/design-principles) — the reasoning behind the capability surface shape. +- [`get_adcp_capabilities` task reference](/dist/docs/3.0.13/protocol/get_adcp_capabilities) — the response schema as documented per-field. +- [Specification Guidelines](/dist/docs/3.0.13/spec-guidelines) — type naming, enum design, vendor-neutral rule. diff --git a/dist/docs/3.0.13/protocol/design-principles.mdx b/dist/docs/3.0.13/protocol/design-principles.mdx new file mode 100644 index 0000000000..25b2b1a8b4 --- /dev/null +++ b/dist/docs/3.0.13/protocol/design-principles.mdx @@ -0,0 +1,148 @@ +--- +title: How AdCP is designed +sidebarTitle: Design principles +description: "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." +"og:title": "AdCP — Design principles" +--- + +# 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. Five 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.0.13/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.0.13/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.0.13/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.0.13/media-buy/task-reference/get_products) and [`create_media_buy`](/dist/docs/3.0.13/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. + +--- + +## Five 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.0.13/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.0.13/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.0.13/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. Sellers declare authorized buyers via [`adagents.json`](/dist/docs/3.0.13/governance/property/adagents); buyers declare brand identity via [`brand.json`](/dist/docs/3.0.13/brand-protocol/brand-json) at `/.well-known/brand.json`. Either party can resolve and verify the other's declarations. The [Registry](/dist/docs/3.0.13/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.0.13/trust) for what AdCP provides versus what it explicitly does not. + +--- + +### 6. Privacy is layered, not uniform + +[Trusted Match Protocol](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/trust#governance) for what AdCP provides versus what it explicitly does not, and [Governance — Embedded human judgment](/dist/docs/3.0.13/governance/embedded-human-judgment) for how HITL surfaces in the protocol. + +--- + +## 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.0.13/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.0.13/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.0.13/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. diff --git a/dist/docs/3.0.13/protocol/format-references.mdx b/dist/docs/3.0.13/protocol/format-references.mdx new file mode 100644 index 0000000000..77b1e16576 --- /dev/null +++ b/dist/docs/3.0.13/protocol/format-references.mdx @@ -0,0 +1,113 @@ +--- +title: Format References +description: "Normative reference for format_id (structured identifier object) vs format (full definition object) in AdCP." +"og:title": "AdCP — Format References" +--- + +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 +{ + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250" +} +``` + +Optional dimension and duration fields extend it for parameterized template formats: + +```json +{ + "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 +{ + "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 +{ "format_id": "display_300x250" } +``` + +Correct: + +```json +{ "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 +{ + "formats": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } + ] +} +``` + +Correct: + +```json +{ + "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.0.13/creative/formats) — full format object structure, asset types, and render specs +- [Template Format IDs](/dist/docs/3.0.13/creative/template-format-ids) — parameterized format IDs for dimension-variable templates diff --git a/dist/docs/3.0.13/protocol/get_adcp_capabilities.mdx b/dist/docs/3.0.13/protocol/get_adcp_capabilities.mdx new file mode 100644 index 0000000000..68b1cc6d05 --- /dev/null +++ b/dist/docs/3.0.13/protocol/get_adcp_capabilities.mdx @@ -0,0 +1,958 @@ +--- +title: get_adcp_capabilities +description: "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." +"og:title": "AdCP — get_adcp_capabilities" +testable: false +--- + +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. + +**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 + +**Request Schema**: [`/schemas/3.0.13/protocol/get-adcp-capabilities-request.json`](https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-request.json) +**Response Schema**: [`/schemas/3.0.13/protocol/get-adcp-capabilities-response.json`](https://adcontextprotocol.org/schemas/3.0.13/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 for mutating requests. See [idempotency](#idempotency). | + +#### idempotency + +Declares whether this seller honors `idempotency_key` replay protection on mutating requests. 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. + +| 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` by `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 +{ + "adcp": { + "major_versions": [3], + "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } + } +} +``` + +Sellers that do not support replay dedup declare it explicitly: + +```json +{ + "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 +{ + "supported_protocols": ["media_buy", "creative"] +} +``` + +Valid values: `media_buy`, `creative`, `signals`, `governance`, `brand`, `sponsored_intelligence`. + +See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for every protocol's scope. Support for the [compliance test controller](/dist/docs/3.0.13/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 +{ + "specialisms": ["sales-guaranteed", "creative-template"] +} +``` + +See the full [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for every specialism and the [enum schema](https://adcontextprotocol.org/schemas/3.0.13/enums/specialism.json) for the authoritative list. + +### 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`. Determines the account model. When `true` (explicit accounts): each operator authenticates independently, buyer discovers accounts via `list_accounts`, passes `account_id`. When `false` (implicit accounts): agent is trusted, buyer declares accounts via `sync_accounts`, passes natural key (`brand` + `operator`). For sandbox, the path follows the account model: explicit accounts discover pre-existing test accounts via `list_accounts`; implicit accounts declare sandbox via `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.0.13/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, the path follows the account model: explicit accounts discover pre-existing test accounts via `list_accounts`; implicit accounts declare sandbox via `sync_accounts` with `sandbox: true` — no real platform calls or spend. See [Sandbox mode](/dist/docs/3.0.13/media-buy/advanced-topics/sandbox). | + +#### Auth models + +**Implicit 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`). + +**Explicit accounts** (`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 discovers accounts via `list_accounts`. The buyer passes seller-assigned `account_id` values on all subsequent requests. + +For sandbox, the path follows the account model: explicit accounts (`require_operator_auth: true`) discover pre-existing test accounts via `list_accounts`; implicit accounts declare sandbox via `sync_accounts` with `sandbox: true`. + +See [Accounts and Agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents#what-sellers-declare) for full workflows and [seller patterns](/dist/docs/3.0.13/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.0.13/media-buy/media-buys/optimization-reporting#offline-file-delivery-based-reporting) for details. + +#### 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` requests | +| `property_list_filtering` | Honors `property_list` parameter in `get_products` | +| `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 +{ + "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.0.13/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.0.13/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.0.13/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 postal code systems are supported: + +| System | Description | +|--------|-------------| +| `us_zip` | US 5-digit ZIP codes (e.g., `10001`) | +| `us_zip_plus_four` | US 9-digit ZIP+4 codes (e.g., `10001-1234`) | +| `gb_outward` | UK postcode district (e.g., `SW1`, `EC1`) | +| `gb_full` | UK full postcode (e.g., `SW1A 1AA`) | +| `ca_fsa` | Canadian Forward Sortation Area (e.g., `K1A`) | +| `ca_full` | Canadian full postal code (e.g., `K1A 0B1`) | +| `de_plz` | German Postleitzahl (e.g., `10115`) | +| `fr_code_postal` | French code postal (e.g., `75001`) | +| `au_postcode` | Australian postcode (e.g., `2000`) | +| `ch_plz` | Swiss Postleitzahl (e.g., `8000`) | +| `at_plz` | Austrian Postleitzahl (e.g., `1010`) | + +#### 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`. Reserved for future use. + +### 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. | + +### 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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 +{ + "$schema": "/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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"] + } + } +} +``` + +### compliance_testing + +Compliance testing capabilities. The presence of this block declares that the agent supports deterministic testing via [`comply_test_controller`](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/comply-test-controller#sandbox-gating). + +| Field | Type | Description | +|-------|------|-------------| +| `scenarios` | string[] | Compliance testing scenarios this agent supports. Possible values: `force_creative_status`, `force_account_status`, `force_media_buy_status`, `force_session_status`, `simulate_delivery`, `simulate_budget_spend`, `seed_product`, `seed_pricing_option`, `seed_creative`, `seed_plan`, `seed_media_buy`. Runners MUST accept unknown scenario strings — new scenarios may be added in additive minor bumps. | + +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 skips unsupported ones. + +:::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.0.13/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` or `media_buy.content_standards.supports_webhook_delivery: 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.0.13/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.0.13/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`. `false` is the recommended posture in 3.x — the HMAC scheme is removed in AdCP 4.0. | + +**Example:** + +```json +{ + "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.0.13/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 +{ + "extensions_supported": ["iab_tcf", "iab_gpp", "acmecorp"] +} +``` + +### 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", "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 +{ + "experimental_features": ["brand.rights_lifecycle", "trusted_match.core"] +} +``` + +See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full stability contract, graduation criteria, and client guidance. + +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 + +## The Capability Contract + +**If a capability is declared, the seller MUST honor it.** + +- `media_buy.execution.targeting.geo_postal_areas.us_zip: true` → Buyer can send US ZIP 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.0.13/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 +import { AdcpClient } from '@adcp/client'; + +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 + if (mediaBuy.execution?.targeting?.geo_postal_areas?.us_zip) { + 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 +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 +// 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) { + if (!mediaBuy.execution?.targeting?.geo_postal_areas?.us_zip) { + 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 +// 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; + +// 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', system: 'us_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 + ...(mediaBuy.execution?.targeting?.geo_postal_areas?.us_zip && { + geo_postal_areas: [{ + system: 'us_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 +// 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 +{ + "$schema": "/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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": { + "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, + "gb_outward": true, + "ca_fsa": true + }, + "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 +{ + "$schema": "/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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": { + "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 + } +} +``` + +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.0.13/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_REQUIRED` | Authentication needed | Provide credentials | +| `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.0.13/building/by-layer/L2/accounts-and-agents#what-sellers-declare) +2. **Filter products**: Use [`get_products`](/dist/docs/3.0.13/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.0.13/media-buy/task-reference/create_media_buy) with supported features + +## Learn More + +- [Accounts and Agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents) - Auth models, account setup, billing +- [adagents.json Specification](/dist/docs/3.0.13/governance/property/adagents) - Publisher authorization files +- [Product Filters](/dist/docs/3.0.13/media-buy/task-reference/get_products#filters) - Capability-aware filtering +- [Content Standards](/dist/docs/3.0.13/governance/content-standards) - Brand safety configuration diff --git a/dist/docs/3.0.13/protocol/required-tasks.mdx b/dist/docs/3.0.13/protocol/required-tasks.mdx new file mode 100644 index 0000000000..f1ffb70281 --- /dev/null +++ b/dist/docs/3.0.13/protocol/required-tasks.mdx @@ -0,0 +1,207 @@ +--- +title: Required tasks by protocol +sidebarTitle: Required tasks +description: "Consolidated reference of required and optional tasks for each AdCP protocol, organized by agent role." +"og:title": "AdCP — Required tasks by protocol" +--- + +# 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.0.13/protocol/get_adcp_capabilities) | + +## 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.0.13/media-buy/specification) · [Seller integration guide](/dist/docs/3.0.13/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.0.13/media-buy/specification#orchestrator-conformance) + +## Creative Protocol + +### Creative agent + +| Task | Requirement | Notes | +|------|------------|-------| +| `list_creative_formats` | Required | Format discovery with technical specs | +| `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.0.13/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.0.13/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.0.13/brand-protocol) · [Building a brand agent](/dist/docs/3.0.13/brand-protocol/building-a-brand-agent) + +## Accounts Protocol + +### Any agent accepting accounts + +| Task | Requirement | Notes | +|------|------------|-------| +| `sync_accounts` | Conditional | Implicit accounts (`require_operator_auth: false`) — buyer declares brand/operator pairs | +| `list_accounts` | Conditional | Explicit accounts (`require_operator_auth: true`) — buyer discovers pre-existing accounts | +| `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 (e.g., ad networks that use implicit accounts on the buyer side and explicit accounts with underlying platforms). + +**Reference**: [Accounts Protocol](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/trusted-match/specification) for message types and conformance requirements. diff --git a/dist/docs/3.0.13/protocol/snapshot-and-log.mdx b/dist/docs/3.0.13/protocol/snapshot-and-log.mdx new file mode 100644 index 0000000000..0d25f16f54 --- /dev/null +++ b/dist/docs/3.0.13/protocol/snapshot-and-log.mdx @@ -0,0 +1,184 @@ +--- +title: Snapshot and log +description: "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." +"og:title": "AdCP — Snapshot and log" +--- + +# 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.0.13/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.0.13/core/mcp-webhook-payload.json); per-type population is documented on [`notification-type.json`](https://adcontextprotocol.org/schemas/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/media-buy/task-reference/get_media_buys#webhook-activity) for the call-site documentation and the [persistent webhook contract](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/by-layer/L3/webhooks) — the transport contract that this page sits on top of. +- [Media buy lifecycle](/dist/docs/3.0.13/media-buy/media-buys/lifecycle) — applies snapshot/log to `status` + `health` + `impairments[]`. diff --git a/dist/docs/3.0.13/quickstart.mdx b/dist/docs/3.0.13/quickstart.mdx new file mode 100644 index 0000000000..0e521774cc --- /dev/null +++ b/dist/docs/3.0.13/quickstart.mdx @@ -0,0 +1,304 @@ +--- +title: Quickstart +description: "Pick your path — buyers call an AdCP agent in 5 minutes, publishers and sellers stand up their own agent." +"og:title": "AdCP — Quickstart" +--- + +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 +export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" +export AGENT_URL="https://test-agent.adcontextprotocol.org/mcp" +``` + +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 +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 +{ + "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 +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 +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 +{ + "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 +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 +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 +{ "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } } +``` + +See the [Security guide](/dist/docs/3.0.13/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 +{ + "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 +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 +{ + "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 +{ + "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's `adagents.json` JWKS, run the AdCP webhook verifier checklist, and reject unknown keys, expired dates, or mismatched digests with a typed `webhook_signature_*` reason code: + +```typescript +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.0.13/building/by-layer/L1/security) and [Webhooks guide](/dist/docs/3.0.13/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 +npm install @adcp/client # JavaScript/TypeScript +pip install adcp # Python +``` + +```javascript +import { AdCPClient } from '@adcp/client'; + +const client = new AdCPClient([{ + id: 'test', + name: 'Test Agent', + agent_uri: 'https://test-agent.adcontextprotocol.org/mcp', + protocol: 'mcp', + auth_token: process.env.ADCP_AUTH_TOKEN, +}]); + +const result = await client.agent('test').executeTask('get_products', { + 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.0.13/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.0.13/building/verification/validate-your-agent)** — test your agent with storyboards and compliance checks +- **[Compliance Catalog](/dist/docs/3.0.13/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.0.13/building/by-layer/L0/mcp-guide)** — transport, sessions, auth details +- **[A2A integration guide](/dist/docs/3.0.13/building/by-layer/L0/a2a-guide)** — streaming, artifacts, push notifications +- **[Task reference](/dist/docs/3.0.13/media-buy/task-reference)** — all available tasks with testable examples +- **[Error handling](/dist/docs/3.0.13/building/operating/transport-errors)** — error codes, recovery strategies +- **[Authentication](/dist/docs/3.0.13/building/by-layer/L2/authentication)** — production credential setup diff --git a/dist/docs/3.0.13/reference/changelog.mdx b/dist/docs/3.0.13/reference/changelog.mdx new file mode 100644 index 0000000000..6fcca0c134 --- /dev/null +++ b/dist/docs/3.0.13/reference/changelog.mdx @@ -0,0 +1,12 @@ +--- +title: Changelog +description: "Link to the AdCP changelog on GitHub, covering breaking changes, new features, and schema updates for every release." +"og:title": "AdCP — Changelog" +--- + + +The detailed technical changelog is maintained in the repository. For high-level summaries and migration guides, see [Release Notes](/dist/docs/3.0.13/reference/release-notes). + +[**View Changelog on GitHub →**](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md) + +AdCP uses [Changesets](https://github.com/changesets/changesets) for version management. Each pull request includes a changeset description compiled into the changelog at release time. All changes follow [semantic versioning](https://semver.org/). diff --git a/dist/docs/3.0.13/reference/experimental-status.mdx b/dist/docs/3.0.13/reference/experimental-status.mdx new file mode 100644 index 0000000000..42f6241e7a --- /dev/null +++ b/dist/docs/3.0.13/reference/experimental-status.mdx @@ -0,0 +1,120 @@ +--- +title: Experimental Status +sidebarTitle: Experimental status +description: "How AdCP marks surfaces that are in the spec but not yet frozen. What experimental means for implementers, what changes inside 3.x, and how an experimental surface graduates to stable." +"og:title": "AdCP — Experimental Status" +--- + +Some AdCP surfaces are published in a release but not yet frozen. They are shipped so implementers can begin building against them, but they carry a weaker stability contract than stable surfaces. This page defines that contract. + +Experimental status is the escape valve that keeps the [3.x stability guarantees](/dist/docs/3.0.13/reference/versioning#3x-stability-guarantees) credible. A surface is either stable — in which case it cannot break inside 3.x — or explicitly experimental, in which case it can. There is no undeclared middle ground. For a list of the protocol's non-goals and deferred items, see [Known Limitations](/dist/docs/3.0.13/reference/known-limitations). + +--- + +## What counts as experimental + +An AdCP surface is experimental when both of the following are true: + +1. Its schema carries `x-status: experimental` at the schema root or on the specific property. +2. A seller implementing it declares the surface in `experimental_features` on its `get_adcp_capabilities` response. + +Both markers are required. The first tells the ecosystem the surface is not frozen; the second tells a specific buyer that this specific seller has opted in to the experimental contract for this surface. + +A seller that implements any experimental surface MUST list it in `experimental_features`. Sellers that do not list an experimental surface MUST NOT implement it — there is no "silently experimental" mode. + +An experimental feature id covers a cluster of related tasks. A seller that implements any task in the cluster (e.g., `get_rights` but not `acquire_rights` or `update_rights`) MUST still declare the cluster's feature id (`brand.rights_lifecycle`). Partial implementation is allowed; silent implementation is not. + + +`x-status: experimental` is a schema-local annotation. It is not inherited through `$ref` — a stable schema that references an experimental sub-schema does not automatically become experimental. The `experimental_features` declaration on `get_adcp_capabilities` is the authoritative runtime signal; `x-status` is an authoring hint for schema readers and tooling. + + +## Contract for experimental surfaces + +**Inside 3.x, experimental surfaces MAY change in ways that stable surfaces cannot:** + +- Fields may be renamed, removed, or have their type changed +- Required fields may become optional and vice versa +- Enums may have values removed or renamed +- Task names may be renamed or removed +- Error codes introduced for an experimental surface may be renamed or removed + +**Notice requirements for breaking changes to experimental surfaces:** + +- At least **6 weeks** published in release notes and the changelog before the change lands +- A migration note describing the change, with before/after examples where possible +- Where feasible, an alias accepting both old and new forms in the release that introduces the change + +This is a deliberate relaxation of the [6-month deprecation notice](/dist/docs/3.0.13/reference/versioning#deprecation-policy) that applies to stable surfaces. + + +**Why experimental exists.** The architecture committee uses this label on surfaces that are genuinely part of the core protocol but not yet field-tested. Without an iteration path, AdCP would either ship rigid schemas nobody has deployed or hold features back until they are perfect. Neither serves implementers. + + +**What does not change for experimental surfaces:** + +- Authentication, transport, and core security requirements. These are version-level concerns and never change inside 3.x, experimental or not. +- Idempotency semantics. A seller's declared idempotency contract applies to experimental surfaces the same way it applies to stable ones. +- Error envelope shape. Experimental surfaces return errors using the same envelope as stable surfaces; only the specific codes may shift. + +## Graduation to stable + +An experimental surface graduates to stable when ALL of the following are met: + +| Criterion | Requirement | +|---|---| +| **Production signal** | At least one implementation running in **production** (not sandbox) for **≥45 days**. | +| **Cross-party validation** | Either (a) a second implementation exists and has been running for ≥45 days, with at least one of the two in production, OR (b) at least one buyer has successfully integrated against the surface in production. Solo-implementer graduation without buyer integration is not allowed. | +| **Schema stability** | No open breaking-change issues against the surface for **≥30 days** prior to graduation. | +| **Deliberate promotion** | A graduation PR removes `x-status: experimental` from the schema and removes the feature id from the canonical experimental list, called out in the release notes for the 3.x release that carries the change. | + +Two implementers is a lower bar than one because cross-implementation friction is what shakes out spec ambiguity. A single implementer can match their own schema by reflex; two cannot. When only one implementer is ready, buyer-integration signal substitutes — that signal covers the buyer-side ergonomic bugs that a solo implementer would otherwise miss. + +Graduation is never automatic. The architecture committee reviews graduation PRs and may require additional cycles if the surface still shows signs of instability. + +### Graduation cadence + +The architecture committee reviews experimental surfaces at each 3.x release. Every release's notes include, for each experimental surface: + +- Current status (still experimental / on track to graduate / under active breaking revision) +- A list of changes the next release is expected to carry +- A pointer to the most recent breaking-change notice for the surface, when applicable + +Enterprise procurement teams can subscribe to release notes to track experimental surfaces on a predictable review cadence; there is no separate mailing list or ticketing process. + +## Client behavior + +Buyers integrating against AdCP sellers SHOULD: + +- **Inspect `experimental_features` before relying on experimental surfaces.** A seller that does not list an experimental surface is asserting it does not implement that surface. +- **Pin to specific 3.x releases** when depending on experimental surfaces, or subscribe to release notes for the features they consume. +- **Design retry and error handling to tolerate new error codes** added to experimental surfaces between releases. +- **Treat experimental surfaces as unsuitable for regulated workflows** without additional vendor assurance. Experimental is not a claim of compliance-grade stability. + +### Buyer-side refusal + +Buyers that do not want to interact with experimental surfaces — typically for regulated workflows, compliance-sensitive deployments, or procurement policies that forbid non-frozen features — enforce this client-side. The pattern: + +1. **At capability discovery**, read `experimental_features` from the seller's `get_adcp_capabilities` response. +2. **Filter before invocation.** Do not call tasks that belong to experimental feature ids your policy rejects. The list of tasks per feature id is published in the canonical experimental-surfaces list below. +3. **Short-circuit upstream.** When an orchestrator or upstream caller requests behavior that would require an experimental surface, return a policy-level error (e.g., the caller's own `POLICY_EXPERIMENTAL_REFUSED`) rather than attempting the call. The refusal is the buyer's concern, not the seller's — sellers MUST NOT be expected to infer buyer policy. + +There is no wire-level refusal field in 3.0. Buyer-side filtering is sufficient and keeps the contract asymmetric (sellers declare; buyers decide). A reciprocal wire mechanism may be revisited in a future release if multi-party refusal handoff patterns emerge from real integrations. + +## Current experimental surfaces + +Schemas marked with `x-status: experimental` are the authoritative source. The canonical list of feature ids for `experimental_features` in AdCP 3.0: + +| Feature id | Surface | Why experimental | +|---|---|---| +| `brand.rights_lifecycle` | [`get_rights`](/dist/docs/3.0.13/brand-protocol/tasks/get_rights), [`acquire_rights`](/dist/docs/3.0.13/brand-protocol/tasks/acquire_rights), [`update_rights`](/dist/docs/3.0.13/brand-protocol/tasks/update_rights); the `brand.rights`, `brand.right_types`, `brand.available_uses`, and `brand.generation_providers` capability fields on `get_adcp_capabilities`; and the `right-use` and `right-type` enums that these surfaces reference | 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. The two enums are marked experimental because their values are expected to evolve as new licensable-use categories surface (e.g. embodiment, hologram, generative-music-style). | +| `governance.campaign` | [`sync_plans`](/dist/docs/3.0.13/governance/campaign/tasks/sync_plans), [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance), [`report_plan_outcome`](/dist/docs/3.0.13/governance/campaign/tasks/report_plan_outcome), [`get_plan_audit_logs`](/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs) | Multi-party governance semantics (buyer vs seller approval conflicts, audit provenance verification, tie-breaking under Embedded Human Judgment) are not yet settled. | +| `trusted_match.core` | [TMP](/dist/docs/3.0.13/trusted-match/) | Privacy architecture is thinly specified relative to what a regulator deep-dive will demand. Exposure tokens, country-partitioned identity, and Offer macros are expected to change. | +| `sponsored_intelligence.core` | [`si_get_offering`](/dist/docs/3.0.13/sponsored-intelligence/tasks/si_get_offering), [`si_initiate_session`](/dist/docs/3.0.13/sponsored-intelligence/tasks/si_initiate_session), [`si_send_message`](/dist/docs/3.0.13/sponsored-intelligence/tasks/si_send_message), [`si_terminate_session`](/dist/docs/3.0.13/sponsored-intelligence/tasks/si_terminate_session); the `sponsored_intelligence` capability field on `get_adcp_capabilities`; the SI identity, capability negotiation, and UI component surfaces defined in the [SI specification](/dist/docs/3.0.13/sponsored-intelligence/specification) | 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. Planned changes track the [3.1.0 roadmap](https://github.com/adcontextprotocol/adcp/issues/2201). | + +Graduation progress and notice of upcoming breaking changes will be called out in the [release notes](/dist/docs/3.0.13/reference/release-notes) for each 3.x release starting with 3.0 GA. + +## Relationship to extensions + +Experimental status is not the same as an [extension](/dist/docs/3.0.13/reference/versioning#extensibility). Extensions live in the `ext.{namespace}` field and are governed by the extension registry; they are permanently out-of-band for the core protocol. Experimental surfaces are in the core protocol — they are candidates for promotion to stable, not third-party additions. + +A surface should be experimental when the architecture committee intends it as part of the core protocol but is not yet willing to freeze it. A surface should be an extension when it is domain-specific, maintained outside the core protocol, or unlikely to apply to the broader AdCP ecosystem. diff --git a/dist/docs/3.0.13/reference/glossary.mdx b/dist/docs/3.0.13/reference/glossary.mdx new file mode 100644 index 0000000000..76d119baa8 --- /dev/null +++ b/dist/docs/3.0.13/reference/glossary.mdx @@ -0,0 +1,440 @@ +--- +title: Glossary +description: "AdCP glossary: definitions of agents, accounts, audiences, signals, governance, media buys, creatives, and other Ad Context Protocol terms." +"og:title": "AdCP — Glossary" +--- + + +## A + +**Account** +A billing relationship between a buyer and a seller. The account determines rate cards, payment terms, and billing entity. An agent may have access to multiple accounts (e.g., an agency managing accounts for different clients). See [Accounts and Agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents). + +**Account Type** +Classification of MCP session credentials as either "platform" (aggregator) or "customer" (direct advertiser/agency). + +**Audience** (AdCP context) +A named group of users uploaded by a buyer for targeted advertising. Distinct from signals: audiences are buyer-owned first-party CRM data uploaded via `sync_audiences`; signals are third-party data discovered via `get_signals`. Audiences are account-scoped — an `audience_id` cannot be used across sellers. Reference audiences in `targeting_overlay.audience_include` or `audience_exclude` in `create_media_buy`. See also [Audience Member](#audience-member) and [Hashed Identifier](#hashed-identifier). + +**Audience Member** +A hashed identifier record representing one person in a CRM audience. Contains at least one of: `hashed_email`, `hashed_phone`, or `uids`. All PII is normalized and hashed (SHA-256) by the buyer before transmission — the wire format never carries cleartext email or phone. Hashing is data minimization at the transport boundary, not anonymization: see [Hashed Identifier](#hashed-identifier) for the pseudonymous-PII caveat that governs retention and consent. Providing multiple identifiers for the same person improves match rates. See also [UID](#uid-universal-id). + +**Audience Constraints** +Plan-level include/exclude rules that structurally prevent prohibited targeting patterns. Each constraint is an [Audience Selector](#audience-selector). The governance agent evaluates `check_governance` requests against these constraints to detect prohibited targeting before any ad is served. + +**Audience Selector** +A discriminated union describing one targeting criterion — either a signal reference (pointing to a specific data provider signal) or a natural language description. Audience selectors appear in plan audience constraints and `planned_delivery.audience_targeting`. + +**Action Source** +Where an event physically occurred: `website`, `app`, `in_store`, `phone_call`, `chat`, `email`, `offline`, `system_generated`, or `other`. Set on each event in `log_event` and declared at the product level in `conversion_tracking.action_sources`. + +**Activation** +The process of making a signal available for targeting on a specific platform and seat. + +**Ad Context Protocol (AdCP)** +An open standard for AI-powered advertising workflows. AdCP defines domain-specific tasks and schemas that work over MCP and A2A as transports, enabling natural language interfaces for advertising operations. + +**Agentic Commerce Protocol (ACP)** +An open standard developed by OpenAI and Stripe for programmatic commerce flows in AI assistants. ACP defines how agents can initiate checkout, delegate payment, and complete transactions without becoming the merchant of record. In the context of Sponsored Intelligence, ACP handles the transaction after a brand agent hands off a user with purchase intent. + +**Agentic eXecution Engine (AXE)** +Deprecated. The original real-time execution layer for impression-time targeting decisions. Replaced by the [Trusted Match Protocol (TMP)](/dist/docs/3.0.13/trusted-match), which adds structural privacy separation and multi-surface support. See [AXE documentation](/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine) for legacy reference. + +**AgenticAdvertising.org** +The member organization that stewards the Ad Context Protocol (AdCP) and related open standards for AI-powered advertising. + +**Agent** (AdCP Context) +The authenticated entity making API calls—may be a brand's internal team, an agency's trading desk, or an automated buying system. Identified by the authentication token. Distinct from "Sales Agent" (the MCP server exposing inventory) and "AI Agent" (the AI assistant). See [Accounts and Agents](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents). + +## B + +**Budget** +Total monetary allocation for a media buy, which can be distributed across multiple packages. + +## C + +**Context Match** +TMP operation that evaluates available packages against content context. Contains artifact IDs, context signals (topics, sentiment, keywords, embedding), and available packages. Carries no user identity. See [Trusted Match Protocol](/dist/docs/3.0.13/trusted-match). + +**Conversion Tracking** +The mechanism for sending buyer-side marketing events to a seller for attribution. Involves two steps: configure event sources on the seller account (`sync_event_sources`), then send events (`log_event`). Seller-level capabilities (supported event types, UID types, attribution windows) are declared in `get_adcp_capabilities`; product-level support is declared in `product.conversion_tracking`. See also [Event](#event) and [Optimization Goal](#optimization-goal). + +**CPC (Cost Per Click)** +Pricing model based on cost per click on the advertisement. + +**CPCV (Cost Per Completed View)** +Pricing model based on cost per 100% video or audio completion. + +**CPM (Cost Per Mille)** +Pricing model based on cost per thousand impressions. Traditional display advertising pricing. + +**CPP (Cost Per Point)** +Pricing model based on cost per Gross Rating Point (GRP), commonly used in TV and audio advertising. + +**CPV (Cost Per View)** +Pricing model based on cost per view at a publisher-defined threshold (e.g., 50% video completion). + +**Completed View** +A video or audio ad that has been viewed to 100% completion. Used for CPCV pricing and completion rate metrics. + +**Completion Rate** +The percentage of video or audio ads that are viewed to 100% completion (completed_views / impressions). + +**Customer Account** +Direct advertiser or agency account with specific seat access and negotiated rates. + +## D + +**Data Provider** +An organization that owns and publishes audience or contextual data (e.g., Polk, Experian, Acxiom). Data providers publish signal catalogs via `/.well-known/adagents.json` on their domain, enabling discovery and authorization verification. See [Data Provider Guide](/dist/docs/3.0.13/signals/data-providers). + +**Data Provider Domain** +The domain where a data provider publishes their signal catalog (e.g., `polk.com`). Used in `signal_id` objects to reference signals: `{ "data_provider_domain": "polk.com", "id": "likely_tesla_buyers" }`. + +**Daypart** +Specific time-of-day segment for time-based advertising, commonly used in DOOH (e.g., morning_commute, evening_prime, overnight). + +**Delegation Type** (`delegation_type`) +Field in adagents.json that describes the commercial relationship between a publisher and an authorized agent: `direct` (publisher treats this as their direct sales path), `delegated` (agent manages monetization on the publisher's behalf), or `ad_network` (inventory sold through a network/package). Corresponds to `relationship` in brand.json for bilateral verification. + +**Deployment** (Signals Protocol) +The availability status of a signal on specific platforms, including activation state and timing. + +**Devices** (Signals Protocol) +Size unit representing unique device identifiers (cookies, mobile IDs) - typically the largest reach metric. + +**Device Type** (Media Buy Protocol) +Targeting and reporting dimension for device form factors: desktop, mobile, tablet, ctv, dooh, unknown. Complements device platform (operating system) with hardware classification. + +**Decisioning Platform** +Technical infrastructure that selects which ad to serve at impression time. Receives activated signals and executes campaigns. Examples: DSPs (The Trade Desk), SSPs (Index Exchange, OpenX, PubMatic), ad servers (Google Ad Manager, Kevel). + +**DOOH (Digital Out-of-Home)** +Digital advertising displayed on screens in public spaces such as billboards, transit stations, airports, and retail locations. Uses CPM or flat_rate pricing with parameters for SOV, duration, and venue targeting. + +**DSP (Demand-Side Platform)** +A type of decisioning platform that allows advertisers to buy advertising inventory programmatically. + +## E + +**Event** +A marketing event logged via `log_event` for attribution and optimization. Covers the full funnel from engagement (`page_view`, `view_content`, `search`) through intent (`add_to_cart`, `initiate_checkout`) to conversion (`purchase`, `lead`, `complete_registration`). Each event has an `event_id` (for deduplication), `event_type`, `event_time`, and optional `user_match` and `custom_data`. Defined in the `event.json` schema. + +**Event Source** +A configured data channel that receives conversion or marketing events from a buyer. Event sources are set up on a seller account via `sync_event_sources` and referenced by `event_source_id` when logging events or setting optimization goals. Can be managed by the buyer (e.g. a website pixel) or by the seller (e.g. Amazon sales attribution). + +**Event Type** +A standardized classification for marketing events (e.g. `purchase`, `lead`, `add_to_cart`, `page_view`, `refund`). Covers the full lifecycle: engagement (`select_content`, `share`), e-commerce (`add_to_cart`, `remove_from_cart`, `viewed_cart`, `purchase`, `refund`), lead management (`lead`, `qualify_lead`, `close_convert_lead`, `disqualify_lead`), and app events. Used to categorize events logged via `log_event` and to specify optimization goals on packages. Aligned with IAB ECAPI. + +**Estimated Activation Time** +Predicted timeframe for signal deployment, typically 24-48 hours for new activations. + +## F + +**Flat Rate** +Fixed-cost pricing model where a single payment is made regardless of delivery volume. Common for sponsorships and takeovers. + +**Flight** +A time-bounded advertising campaign segment, mapped to line items in ad servers. + +**Frequency** +The average number of times an individual is exposed to an advertisement during a campaign. + +## G + +**GRP (Gross Rating Point)** +A unit of measurement for television and radio advertising representing 1% of the target audience. Used in CPP pricing models. Total GRPs = Reach % × Average Frequency. Measured by third-party providers such as Nielsen, Comscore, iSpot.tv, or Triton Digital. + +## H + +**Hashed Identifier** +Buyer-normalized personally identifiable information (PII) hashed with SHA-256 before transmission. Used in audience uploads (`sync_audiences`) and event attribution (`log_event`). Normalization: emails to lowercase+trim, phone numbers to E.164 format. The seller matches by independently hashing its own user data with the same algorithm. Unsalted SHA-256 of email or phone is **pseudonymous PII, not anonymous**: the input namespace is small enough that precomputed dictionaries recover plaintext, so hashed identifiers MUST be treated as PII for retention, consent, and access-control purposes. See [Privacy Considerations](/dist/docs/3.0.13/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous). Distinct from [UIDs](#uid-universal-id), which are tokenized identifiers resolved by identity graph operators (e.g. UID2, RampID) under their own privacy architectures. + +**Households** +Size unit representing unique household addresses, useful for geographic and family-based targeting. + +**Human-in-the-Loop (HITL)** +Protocol feature allowing publishers to require manual approval for operations. + +## I + +**Identity Match** +TMP operation that evaluates user eligibility against package criteria using an opaque user token. Returns a boolean `eligible` flag and optional `intent_score` per package. Carries no page context. The buyer computes eligibility from frequency caps, audience membership, and other signals; the reasons are opaque to the publisher. See [Trusted Match Protocol](/dist/docs/3.0.13/trusted-match). + +**Idempotency** +The property that repeating the same operation produces the same result as running it once, with no extra side effects. Every mutating AdCP request carries a required `idempotency_key`; on retry with the same key and payload, the seller returns the cached response with `replayed: true` instead of re-executing. This is what lets an agent safely retry a `create_media_buy` after a network timeout without creating two buys. See the [Security Model](/dist/docs/3.0.13/building/concepts/security-model#layer-3-idempotency--at-most-once-execution) for the narrative and [Security: Request Safety](/dist/docs/3.0.13/building/by-layer/L1/security#idempotency) for the normative rules. + +**Idempotency Key** +A unique, unguessable identifier (UUID v4 or equivalent CSPRNG output) attached to every mutating AdCP request. Scoped per `(authenticated agent, account)`. The seller uses it as the cache key for at-most-once execution within the declared `replay_ttl_seconds` window (minimum 1 hour, recommended 24 hours, maximum 7 days). Same key + same payload = replay; same key + different payload = `IDEMPOTENCY_CONFLICT`. See [Security: Request Safety](/dist/docs/3.0.13/building/by-layer/L1/security#idempotency). + +**Impressions** (Media Buy Protocol) +The number of times an ad is displayed, used for pricing and delivery tracking. + +**Individuals** (Signals Protocol) +Size unit representing unique people, best for frequency capping and demographic targeting. + +**Inventory** +Available advertising space on websites, apps, or other media properties. + +## L + +**Line Item** +The basic unit of inventory in ad servers like Google Ad Manager, represented as packages in AdCP. + +**Loop Duration** +The length of time for a complete rotation of ads in a DOOH display, measured in seconds. Used to calculate frequency and share of voice. + +**Loop Plays** +The number of times an ad was displayed in a DOOH loop rotation. Key metric for DOOH delivery reporting. + +## M + +**Marketplace Signal** +Third-party signal available for licensing from data providers. + +**MCP (Model Context Protocol)** +The underlying protocol framework that enables AI assistants to interact with external systems. + +**Media Buy** +A complete advertising campaign containing packages, budget, targeting, and creative assets. + +## N + +**Natural Language Processing** +The AI capability that allows audience discovery through conversational descriptions rather than technical parameters. + +## O + +**Offer** (TMP) +A buyer's response to a TMP context match request. Ranges from simple activation (`package_id` only, for GAM key-value targeting) to rich proposals with brand, price, summary, and inline creative manifest (for AI assistants and dynamic surfaces). See [TMP Specification](/dist/docs/3.0.13/trusted-match/specification). + +**Optimization Goal** +A single entry in a package's `optimization_goals` array. Two kinds: `kind: "event"` (optimize for advertiser-tracked conversion events via an `event_sources` array of source-type pairs, with optional `cost_per`, `per_ad_spend`, or `maximize_value` target) and `kind: "metric"` (optimize for a seller-native delivery metric — clicks, views, completed views, engagements, attention, etc. — with optional `cost_per` or `threshold_rate` target). Event goals with `per_ad_spend` or `maximize_value` targets require `value_field` on event source entries. When the seller supports `multi_source_event_dedup` (declared in `get_adcp_capabilities`), they deduplicate by `event_id` across sources; otherwise buyers should use a single event source per goal. When multiple goals are present, `priority` (1 = highest) controls which the seller treats as primary. + +**Owned Signal** +First-party signal data belonging to the advertiser or platform. + +## P + +**Package** +A specific advertising product within a media buy, representing a flight or line item with its own pricing and targeting. + +**Platform Account** +Master account representing an advertising platform that can syndicate signals to multiple customers. + +**Policy Category** +A regulatory regime that applies to a campaign plan (e.g., `children_directed`, `fair_housing`, `fair_lending`, `fair_employment`). Each policy category defines `restricted_attributes` — personal data types that must not be used for targeting when that category applies. Governance agents resolve policy categories from both the plan and the brand's compliance configuration. + +**Pricing Model** +The method by which advertising inventory is priced and billed. AdCP supports CPM, CPC, CPCV, CPV, CPA, CPL, CPP, and flat rate models. + +**Pricing Option** +A specific pricing model offered by a publisher for a product, including rate, currency, and parameters. + +**Principal** +*Deprecated term.* See [Account](#a) (billing relationship) and [Agent](#a) (authenticated caller). Previously used to refer to the authenticated entity with its associated platform mappings. AdCP now splits these responsibilities: the **Agent** handles authentication and API access, while the **Account** handles billing, rate cards, and platform mappings. + +**Product** +Advertising inventory available for purchase, discovered through natural language queries. + +**Prompt** +Natural language description used to discover relevant signals (e.g., "high-income sports enthusiasts", "premium automotive content", "users in urban areas during evening hours"). + +**Property** +A digital advertising surface: website, mobile app, CTV app, podcast, or other media endpoint. Properties appear in `adagents.json` (publisher-declared inventory) and `brand.json` (operator-declared portfolio with `relationship` field). Identified by type + identifier (domain for websites, bundle ID for apps). + +**Provider** +The company or platform that supplies signal data (e.g., LiveRamp, Experian, Peer39, weather services). + +## Q + +**Quartile (Video)** +Milestones in video ad viewing: Q1 (25% viewed), Q2 (50% viewed), Q3 (75% viewed), Q4 (100% complete). Used to measure video engagement. + +## R + +**Reach** +The number or percentage of unique individuals exposed to an advertisement at least once during a campaign. + +**Relationship** (brand.json field) +Field on brand.json properties declaring how the brand relates to a property: `owned` (default), `direct`, `delegated`, or `ad_network`. Uses the same values as `delegation_type` in adagents.json. Non-owned properties create bilateral verification — the operator declares the relationship in brand.json, the publisher confirms by authorizing the operator's agent in adagents.json. The AdCP equivalent of sellers.json. + +**Restricted Attribute** +A personal data category (e.g., `health_data`, `racial_ethnic_origin`) that must not be used for targeting under one or more policy categories. Restricted attributes are defined at the registry level (GDPR Article 9 categories) and self-declared on signal definitions. Governance agents match restricted attributes structurally — if a signal carries a restricted attribute that a plan's policy categories prohibit, targeting with that signal is denied. + +**Relevance Score** +Numerical rating (0-1) indicating how well a signal matches the discovery prompt. + +**Relevance Rationale** +Human-readable explanation of why an audience received its relevance score. + +**Revenue Share** +Pricing model based on a percentage of media spend rather than fixed CPM. + +## S + +**Sales Agent** +An MCP server that exposes publisher inventory for discovery and purchase. Handles product discovery, media buy creation, and campaign management. Examples: Publisher ad servers exposing AdCP interfaces, sales house platforms. + +**Seller** +The AdCP participant role that provides advertising inventory. A seller exposes products, accepts media buys, manages creative delivery, and (optionally) provides conversion tracking capabilities. In AdCP schemas, "seller" is the standard term for this role (not "platform"). The seller's technical capabilities are declared via `get_adcp_capabilities`. + +**Screen Time** +Total duration an ad was displayed across all DOOH screens, measured in seconds. Used for DOOH delivery reporting. + +**Seat** +A specific advertising account within a decisioning platform, typically representing a brand or campaign. + +**Segment ID** +The specific identifier used for signal activation, may differ from signal_id. + +**Share of Voice (SOV)** +Percentage of available ad inventory allocated to a specific advertiser in DOOH loops. Expressed as 0.0-1.0 (e.g., 0.15 = 15% SOV). + +**Signal Agent** +A server (via MCP or A2A) that provides signal discovery and activation services. Enables natural language audience discovery and deploys signals to decisioning platforms. Can be private (owned by a single buyer and visible only to that buyer's accounts) or marketplace (licensing data across multiple accounts). Signal agents are authorized by data providers via `adagents.json` to resell specific signals. Examples: LiveRamp, The Trade Desk, Peer39. + +**Signal Catalog** +A collection of signal definitions published by a data provider via `/.well-known/adagents.json`. The catalog includes signal metadata (id, name, value_type, tags) and authorization declarations for signals agents. See [Data Provider Guide](/dist/docs/3.0.13/signals/data-providers). + +**Signal Definition** +A single signal entry in a data provider's signal catalog, containing: `id` (unique identifier), `name` (human-readable), `value_type` (binary, categorical, or numeric), and optional fields like `description`, `tags`, `allowed_values`, and `range`. + +**Signal Discovery** +The process of finding relevant data signals (audiences, contextual, geographical, temporal) using natural language descriptions or structured ID lookup. + +**Signal ID** +A structured identifier referencing a signal. Uses `source` as a discriminator with two variants: `catalog` signals reference a data provider's published catalog (`data_provider_domain` + `id`, verifiable); `agent` signals are native to the signals agent (`agent_url` + `id`, trust-based). Example catalog signal: `{ "source": "catalog", "data_provider_domain": "polk.com", "id": "likely_tesla_buyers" }`. Example agent signal: `{ "source": "agent", "agent_url": "https://liveramp.com/signals", "id": "custom_segment" }`. + +**Signal Source** +The origin type for a signal identifier: `catalog` (from a data provider's published catalog, authorization verifiable via adagents.json) or `agent` (native to the signals agent, not externally verifiable). See [Data Provider Guide](/dist/docs/3.0.13/signals/data-providers). + +**Signal Tags** +Tags used to group related signals within a data provider's catalog. Enable efficient authorization (authorize "all automotive signals" rather than listing individual IDs). Tags must be lowercase alphanumeric with underscores/hyphens. + +**Signal Type** +Classification of signals as "marketplace" (third-party), "owned" (first-party), "destination" (bundled with media), "contextual", "geographical", or "temporal". + +**Signal Value Type** +The data type of a signal's values: `binary` (user matches or doesn't), `categorical` (user has one of several possible values), or `numeric` (user has a score within a range). Determines the targeting expression format. + +**Size Unit** (Signals Protocol) +The measurement type for signal size: individuals, devices, or households. + +**Specialism** +A specific capability claim an agent declares in `get_adcp_capabilities.specialisms`. Each specialism has a wire ID in `kebab-case` (e.g. `sales-guaranteed`, `property-lists`) and a matching storyboard bundle published at `/compliance/{version}/specialisms/{id}/`. The AAO compliance runner executes the matching storyboards to verify the claim. See [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) for the list of specialism IDs; claim one by adding it to your `get_adcp_capabilities.specialisms` array, then run [Validate Your Agent](/dist/docs/3.0.13/building/verification/validate-your-agent) to see how the runner maps it to storyboards. + +**Sponsored Intelligence (SI)** +An open standard for conversational brand experiences in AI assistants. Like VAST defines video ad serving, SI defines how to serve and interact with brand agent endpoints. SI handles the engagement; the Agentic Commerce Protocol (ACP) handles transactions. See [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) for details. + +**SSP (Supply-Side Platform)** +A type of decisioning platform that helps publishers sell advertising inventory programmatically. SSPs connect to multiple demand sources and make ad selection decisions. Examples: Index Exchange, OpenX, PubMatic, Magnite. + +**SSRF (Server-Side Request Forgery)** +A class of attack where an adversary supplies a URL that tricks an agent into making an HTTP request to an address it shouldn't — typically an internal service, a cloud metadata endpoint (e.g., `169.254.169.254`), or a localhost port. Every URL an AdCP counterparty supplies (webhook callbacks, TMP provider endpoints, governance `jwks_uri`, reporting buckets, `adagents.json` `authoritative_location`) is a potential vector. Defense is implemented in code via a 6-point check: HTTPS-only, reserved-IP deny list, IP pinning against DNS rebinding, no redirects, response size and timeout caps, suppressed error detail. See the [Security Model](/dist/docs/3.0.13/building/concepts/security-model) for the narrative and [Security: Webhook URL validation](/dist/docs/3.0.13/building/by-layer/L1/security#webhook-url-validation-ssrf) for the normative rules. + +**Storyboard** +A compliance test bundle that verifies a protocol baseline or [specialism](#specialism) claim. Each storyboard is a YAML file under `/compliance/{version}/` that declares an `id`, phases of test steps, sample requests, and validations the agent must pass. Storyboards live in three buckets: `universal/` (applies to every agent), `protocols/{protocol}/` (one baseline per supported protocol), and `specialisms/{id}/` (one per specialism claim). + +**Storyboard Category** +The `snake_case` identifier in a storyboard's `category:` frontmatter field. For specialisms, the category is the specialism's kebab-case wire ID with hyphens swapped to underscores — e.g. specialism ID `sales-streaming-tv` has category `sales_streaming_tv`. Variant scenarios within a specialism use `{category}/{variant}` form (e.g. `governance_spend_authority/denied`). The kebab↔snake split is deliberate: specialism IDs are URL path segments, storyboard categories are wire enums. + +## T + +**Takeover** +Exclusive 100% share of voice placement on DOOH inventory for a specific time period. Priced as flat_rate with sov_percentage: 100. + +**Third-Party Signal** +Signal data licensed from external providers, also known as marketplace signals. + +**Time-Based Pricing** +Pricing structure based on duration (hourly, daily, or daypart) rather than impressions. Common in DOOH advertising using flat_rate model. + +**TMP Router** +Infrastructure that fans out TMP requests to buyer agents and merges responses. A single binary with structurally separate code paths for context and identity. The router enriches requests (adding property registry IDs, signing), enforces privacy constraints (rejecting requests with identity data in the context path), and applies adaptive timeouts. See [Router Architecture](/dist/docs/3.0.13/trusted-match/router-architecture). + +**Trusted Match Protocol (TMP)** +AdCP's real-time execution layer. Determines which pre-negotiated packages should activate for a given impression using two structurally separated operations: Context Match (content fit, no user identity) and Identity Match (user eligibility, no page context). The publisher joins both responses locally. Works across web, mobile, CTV, AI assistants, and retail media. Replaces AXE. See [Trusted Match Protocol](/dist/docs/3.0.13/trusted-match). + +## U + +**UID (Universal ID)** +An already-resolved, privacy-preserving user token from an identity graph. Used for cross-platform user matching. Distinct from [Hashed Identifiers](#hashed-identifier): UIDs are pre-resolved tokens (not raw PII); hashed identifiers are buyer-normalized PII hashed before sending. AdCP supports: `rampid` (LiveRamp RampID), `id5` (ID5), `uid2` (Unified ID 2.0), `euid` (European Unified ID), `pairid` (IAB Tech Lab PAIR), and `maid` (Mobile Advertising ID — IDFA/GAID). UIDs are accepted in audience uploads (`sync_audiences` → `audience_member.uids`) and event attribution (`log_event` → `user_match.uids`). Supported types vary by seller — check `get_adcp_capabilities` → `audience_targeting.supported_uid_types`. + +**Universal Commerce Protocol (UCP)** +An open standard developed by Google with Shopify, Walmart, Target, and others for commerce in AI assistants. UCP defines primitives for checkout, payments, and fulfillment. Along with ACP (Agentic Commerce Protocol), UCP represents the commerce layer that complements AdCP's advertising layer. + +**User Match** +The set of identifiers on an event used to attribute it to ad delivery. Includes universal IDs (`uids`), hashed identifiers (`hashed_email`, `hashed_phone` — SHA-256, normalized before hashing), `click_id` (platform-specific click identifiers like fbclid, gclid), and optional `client_ip` + `client_user_agent` for probabilistic matching. At least one identifier is required. + +**Usage Reporting** +Daily reporting of signal utilization for billing and optimization purposes. + +## V + +**Venue** +Physical location where DOOH advertising is displayed (e.g., airport terminal, transit station, retail store). Used for DOOH targeting and delivery reporting. + +**Venue Package** +Named collection of DOOH screens across specific venues (e.g., 'times_square_network', 'airport_terminals'). Used in DOOH pricing parameters. + +## Acronyms + +- **ACP**: Agentic Commerce Protocol +- **AdCP**: Ad Context Protocol +- **API**: Application Programming Interface +- **AXE**: Agentic eXecution Engine +- **CPA**: Cost Per Acquisition +- **CPM**: Cost Per Mille (thousand) +- **DMP**: Data Management Platform +- **DSP**: Demand-Side Platform +- **ECAPI**: Enhanced Conversions API (IAB Tech Lab) +- **EUID**: European Unified ID +- **MCP**: Model Context Protocol +- **PII**: Personally Identifiable Information +- **ROAS**: Return On Ad Spend +- **RTB**: Real-Time Bidding +- **SI**: Sponsored Intelligence +- **SSP**: Supply-Side Platform +- **TMP**: Trusted Match Protocol +- **TTD**: The Trade Desk +- **UCP**: Universal Commerce Protocol +- **UID**: Universal Identifier +- **UTC**: Coordinated Universal Time + +## Units and Measurements + +**Time Formats** +- All timestamps use ISO 8601 format (e.g., "2025-01-20T14:30:00Z") +- Dates use YYYY-MM-DD format +- Activation times expressed as human-readable estimates ("24-48 hours") + +**Currency** +- All pricing in specified currency (typically USD) +- CPM expressed as cost per 1,000 impressions +- Revenue share expressed as decimal (0.15 = 15%) + +**Size Reporting** (Signals Protocol) +- Counts expressed as integers +- Units clearly specified (individuals/devices/households) +- Dated with "as_of" timestamp for freshness + +**Impression Reporting** (Media Buy Protocol) +- Delivery counts by package +- Pacing metrics for optimization +- Dimensional breakdowns available + +## Error Codes Reference + +Common error codes across all AdCP implementations: + +- `REFERENCE_NOT_FOUND`: Invalid or expired segment ID (or any referenced resource that does not exist or is not accessible; `error.field` identifies the failing parameter). Replaces the removed-in-rc.5 codes: `SEGMENT_NOT_FOUND`, `SIGNAL_AGENT_SEGMENT_NOT_FOUND`, `AGENT_NOT_FOUND`, `AUDIENCE_NOT_FOUND`, `CATALOG_NOT_FOUND`, `EVENT_SOURCE_NOT_FOUND`, `FORMAT_NOT_FOUND`, `STANDARDS_NOT_FOUND`, `BRAND_NOT_FOUND`, `CHECK_NOT_FOUND`, `CAMPAIGN_NOT_FOUND`. See [release notes](/dist/docs/3.0.13/reference/release-notes#version-3-0-0-rc-5) for the migration. +- `ACTIVATION_FAILED`: Unable to complete activation process +- `ALREADY_ACTIVATED`: Signal already active for platform/seat +- `DEPLOYMENT_UNAUTHORIZED`: Insufficient permissions for platform/seat +- `BUDGET_EXCEEDED`: Operation would exceed allocated budget +- `CREATIVE_REJECTED`: Creative asset failed platform review +- `INVALID_PRICING_MODEL`: Requested pricing model unavailable +- `RATE_LIMITED`: Too many requests in time window +- `AUTHENTICATION_FAILED`: Invalid or expired credentials +- `VALIDATION_ERROR`: Request format or parameter errors \ No newline at end of file diff --git a/dist/docs/3.0.13/reference/gmsf-reference.mdx b/dist/docs/3.0.13/reference/gmsf-reference.mdx new file mode 100644 index 0000000000..6c5eae0582 --- /dev/null +++ b/dist/docs/3.0.13/reference/gmsf-reference.mdx @@ -0,0 +1,167 @@ +--- +title: GMSF Reference +description: "GMSF reference for AdCP: Global Media Sustainability Framework carbon measurement standards, metrics, and how they apply to agentic advertising campaigns." +"og:title": "AdCP — GMSF Reference" +--- + +# GMSF Reference + +Token-efficient reference for the Global Media Sustainability Framework. Designed for AI agents discussing advertising sustainability. + +## What is GMSF? + +The **Global Media Sustainability Framework (GMSF)** is an industry standard for measuring and reducing carbon emissions from media and advertising. Developed by Ad Net Zero and industry stakeholders. + +**Goal**: Standardized carbon measurement across the advertising supply chain. + +## Key Organizations + +| Organization | Role | +|--------------|------| +| **Ad Net Zero** | Industry coalition leading sustainability initiatives | +| **WFA** | World Federation of Advertisers - coordinates brand commitments | +| **IAB** | Provides technical standards and implementation guidance | +| **Scope3** | Carbon measurement platform for media | + +## Carbon Emission Sources in Ad Tech + +### By Supply Chain Stage + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CAMPAIGN CREATION │ +│ Creative production, asset hosting, transcoding │ +│ Carbon: Medium │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AD DECISIONING │ +│ Bid requests, auction processing, ML inference │ +│ Carbon: HIGH (biggest contributor in programmatic) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ AD DELIVERY │ +│ CDN distribution, ad rendering, tracking pixels │ +│ Carbon: Medium-High │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MEASUREMENT │ +│ Attribution, reporting, analytics │ +│ Carbon: Low-Medium │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Programmatic Waste Points + +| Issue | Carbon Impact | Description | +|-------|---------------|-------------| +| **Bid requests** | Very High | Billions of requests, most fail to win | +| **Header bidding** | High | Multiple SSPs = multiplied requests | +| **Reselling** | High | Same impression sold multiple times | +| **MFA sites** | High | Made-for-advertising sites with low value | +| **Invalid traffic** | High | Bots generating worthless impressions | + +## GMSF Measurement Framework + +### Scope Categories + +| Scope | Definition | Examples in Ad Tech | +|-------|------------|---------------------| +| **Scope 1** | Direct emissions | Office HVAC, company vehicles | +| **Scope 2** | Indirect from energy | Data center electricity | +| **Scope 3** | Value chain emissions | Media buying, creative production | + +**Note**: Most advertising carbon is Scope 3 (downstream supply chain). + +### Key Metrics + +| Metric | Definition | Target | +|--------|------------|--------| +| **gCO2e/impression** | Grams CO2 equivalent per ad impression | Lower is better | +| **gCO2e/1000 impressions** | Per-mille carbon | Industry benchmarks | +| **Supply path carbon** | Emissions from intermediaries | Minimize hops | + +### Typical Carbon Values + +| Channel | gCO2e per 1000 impressions | Notes | +|---------|---------------------------|-------| +| Direct sold | 50-150 | Shortest path | +| Programmatic display | 200-400 | Many intermediaries | +| Programmatic video | 400-800 | Larger files + auctions | +| CTV programmatic | 300-600 | Depends on supply path | + +*Values are approximate and vary by measurement methodology.* + +## How AdCP Reduces Carbon + +| Programmatic Problem | AdCP Solution | Carbon Reduction | +|---------------------|---------------|------------------| +| Billions of bid requests | Direct negotiation | 90%+ fewer requests | +| Multi-hop supply paths | Publisher-direct | Eliminated intermediaries | +| Real-time auctions | Asynchronous decisions | Reduced server compute | +| Cookie syncing | Context-based matching | Eliminated sync traffic | +| Speculative bidding | Intent-based buying | Only requested inventory | + +### Quantified Benefits + +``` +Traditional Programmatic: +1 impression = 50+ bid requests × 10+ SSPs = 500+ server calls + +AdCP Agentic: +1 impression = 1 request to sales agent = 1 server call + +Reduction: ~99% fewer server calls +``` + +## Sustainability Best Practices + +### For Buyers + +| Practice | Impact | +|----------|--------| +| Reduce supply chain hops | -30-50% carbon | +| Use direct deals over open auction | -40-60% carbon | +| Avoid MFA inventory | -20-40% carbon | +| Choose green-certified publishers | Variable | +| Optimize creative file sizes | -10-20% carbon | + +### For Publishers + +| Practice | Impact | +|----------|--------| +| Reduce header bidding partners | -20-40% carbon | +| Implement lazy loading | -15-25% carbon | +| Optimize ad refresh frequency | -10-30% carbon | +| Use renewable energy hosting | -50-90% carbon | +| Enable agentic/direct channels | -60-90% carbon | + +## GMSF Resources + +| Resource | URL | +|----------|-----| +| Ad Net Zero | [adnetzero.com](https://adnetzero.com) | +| GMSF Documentation | [wfanet.org/gmsf](https://wfanet.org/leadership/gmsf) | +| Scope3 | [scope3.com](https://scope3.com) | +| IAB Sustainability | [iab.com/sustainability](https://www.iab.com/topics/sustainability/) | + +## Key Terms + +| Term | Definition | +|------|------------| +| **Carbon offset** | Compensating emissions by funding carbon removal | +| **Carbon neutral** | Net zero emissions (offsets allowed) | +| **Net zero** | Actual zero emissions (no offsets) | +| **Science-based targets** | Reduction goals aligned with climate science | +| **GHG Protocol** | Standard for measuring greenhouse gases | +| **MFA** | Made-for-advertising (low-quality sites) | +| **SPO** | Supply path optimization | + +--- + +*This is a placeholder document. Content will be enhanced with more detail and citations in future updates.* diff --git a/dist/docs/3.0.13/reference/implementor-faq.mdx b/dist/docs/3.0.13/reference/implementor-faq.mdx new file mode 100644 index 0000000000..521e7dbdae --- /dev/null +++ b/dist/docs/3.0.13/reference/implementor-faq.mdx @@ -0,0 +1,328 @@ +--- +title: Implementor FAQ +description: "AdCP implementor FAQ: answers to common questions about get_products parameters, creative sync, delivery reporting, and building sales agents." +"og:title": "AdCP — Implementor FAQ" +--- + + +Common questions from teams building AdCP sales agents and integrations, with direct answers based on the specification. + +## Product Discovery + +### Q: Does `get_products` require specific parameters like budget, dates, and objectives? + +**A:** Currently, `get_products` only has three parameters: `brief` (natural language string), `brand`, and `filters` (structured filters). Campaign details like budget, dates, and objectives should be included in the natural language `brief`. + +**Future:** Structured parameters for budget, dates, objectives, and targeting are being considered to reduce conversational back-and-forth. Track the roadmap for updates. + +**Current Workaround:** Include these details in your brief: +```json +{ + "brand": { + "domain": "acmecorp.com" + }, + "brief": "Tech startup needs display and video inventory to reach IT decision makers. Budget: $25K. Timeline: March 1-31. Objective: Lead generation with 2% conversion target." +} +``` + +### Q: How do I request specific audience targeting in `get_products`? + +**A:** All targeting requests currently go in the natural language `brief`. There's no structured targeting filter yet. + +**Example:** +```json +{ + "brand": { + "domain": "energydrink.com" + }, + "brief": "Target sports fans, ages 18-34, in major US cities for energy drink campaign" +} +``` + +The publisher's AI interprets this and returns relevant products. Future versions may add structured targeting filters. + +### Q: What does "no brief = standard catalog" mean? We don't have a standard catalog. + +**A:** When buyers omit the `brief` field, they're requesting your standard catalog - baseline products available to all advertisers without custom recommendations. + +**If you don't offer a standard catalog**, return an error: +```json +{ + "message": "We require a campaign brief to recommend products. Please provide details about your campaign goals, audience, and objectives.", + "products": [] +} +``` + +**If you do offer standard catalog**, return standard products matching the provided filters (format types, delivery type, etc.). + +### Q: Should buyers call `list_creative_formats` before or after `get_products`? + +**A:** **After `get_products`**. The recommended flow is: + +1. Call `get_products` with your campaign brief/filters +2. Review the products returned (they include `format_ids` arrays) +3. Call `list_creative_formats` for the specific format IDs you need details on +4. Verify creative requirements match your capabilities +5. Call `create_media_buy` for selected products + +**Why:** You don't know which format IDs you need until you see which products are available. Getting all formats upfront is inefficient. + +## Policy Compliance + +### Q: How do publishers know if there's an ad policy issue (alcohol, adult content, etc.)? + +**A:** Publishers extract advertiser identity from the `brand` field: + +1. **Extract advertiser info** from the brand's `brand.json` (resolved via `brand.domain`) +2. **Check what's being promoted** from the `brief` text +3. **Apply policy rules** based on your publisher policies +4. **Return appropriate response:** + - Allowed: Return products normally + - Blocked: Return empty products array with policy explanation + - Restricted: Indicate manual approval needed + +**Example blocked response:** +```json +{ + "message": "I'm unable to offer products for this campaign. Our publisher policy prohibits alcohol advertising without age verification capabilities.", + "products": [] +} +``` + +See [Policy Compliance](/dist/docs/3.0.13/media-buy/media-buys/policy-compliance) for complete implementation guidance. + +### Q: Is the advertiser's name always shared? + +**A:** Yes, the `brand` field is required in both `get_products` and `create_media_buy`. It provides the advertiser identity needed for: +- Policy compliance checks +- Business relationship management (KYC) +- Billing and reporting + +Brand references are simple: +```json +{ + "brand": { + "domain": "acmecorp.com" + } +} +``` + +The brand's `brand.json` (resolved from the domain) provides category and other identity data for automated policy filtering. + +## Schema and Fields + +### Q: Why is the `filters` parameter called an "object"? + +**A:** Because it's a nested JSON object with multiple optional fields: + +```json +{ + "filters": { + "delivery_type": "guaranteed", + "standard_formats_only": true, + "is_fixed_price": true, + "min_exposures": 10000 + } +} +``` + +It's not a single filter—it's a collection of filter criteria for product catalog search. + +### Q: Why is it called `format_ids` instead of `ad_units`? + +**A:** AdCP uses protocol-agnostic terminology: + +- **`format_ids`**: Structured references to creative format specifications (e.g., `{agent_url: "...", id: "video_30s_hosted"}`) +- **Ad units**: Platform-specific terminology (like "300x250 banner") + +Formats are abstract specifications that work across all ad platforms. The `_ids` suffix indicates these are references—use `list_creative_formats` to get full format objects. + +### Q: How do I specify what's being promoted? + +**A:** There are two mechanisms depending on the context: + +- **Advertiser identity** → `brand.domain` (required on `get_products` and `create_media_buy`, resolves via `/.well-known/brand.json`) +- **What's being promoted** → Described in the `brief` field for product discovery, or provided via a `catalog` on creatives + +For product discovery: +```json +{ + "brand": { + "domain": "nike.com" + }, + "brief": "Nike Air Max 2024 - latest innovation in cushioning technology targeting runners and fitness enthusiasts" +} +``` + +For catalog-driven creatives, reference a synced catalog: +```json test=false +{ + "creative_id": "product-carousel", + "format_id": { "agent_url": "...", "id": "product_carousel" }, + "catalogs": [{ + "catalog_id": "product-feed", + "type": "product" + }] +} +``` + +## Brief Processing + +### Q: What if buyers provide incomplete briefs? + +**A:** Publishers should request clarification when critical information is missing: + +```json +{ + "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?", + "products": [] +} +``` + +This maintains a conversational, helpful approach while gathering needed context. + +### Q: Should we always ask for clarification or just return products? + +**A:** It depends on your publisher strategy: + +- **High-touch approach**: Request clarification for incomplete briefs, engage conversationally +- **Self-service approach**: Return best-guess products based on available information + +Both are valid. Consider your target buyer personas and automation level. + +## Workflow and Integration + +### Q: When should `brand` be required vs optional? + +**A:** According to the current spec: +- **Required in both `get_products` AND `create_media_buy`** + +Best practice: Always require it. Policy checking should happen during discovery, not at purchase time. + +### Q: Can buyers cache product responses? + +**A:** Products represent inventory availability which changes over time. Recommendations: +- **Brief-based discovery**: Don't cache—products are contextually matched to the brief +- **Standard catalog**: Can cache for short periods (5-15 minutes) if your catalog is stable +- **Product details**: Cache `product_id` mappings but revalidate availability before purchase + +### Q: How do we handle large product catalogs (1000+ products)? + +**A:** Use `property_tags` instead of full `properties` arrays: + +```json +{ + "product_id": "local_radio_midwest", + "property_tags": ["local_radio", "midwest"], + "format_ids": [...] +} +``` + +Buyers can discover the agent's portfolio via [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities), which returns the publisher domains and primary channels the agent represents. This keeps responses lightweight while maintaining full validation capability. + +## Testing and Validation + +### Q: How do we test policy compliance? + +**A:** Create test cases with known restricted categories: + +```javascript +// Test blocked category +const response = await get_products({ + brand: { + domain: "test-alcohol.example.com" + }, + brief: "Promote our new craft beer" +}); + +assert(response.products.length === 0); +assert(response.message.includes("policy")); +``` + +### Q: What should we test in integration testing? + +**A:** Key scenarios to cover: + +1. **No brief + filters** → Standard catalog +2. **Brief provided** → AI-matched products with `brief_relevance` +3. **Blocked advertiser** → Policy error +4. **Incomplete brief** → Clarification request +5. **No products match** → Helpful alternative suggestions +6. **Format filtering** → Only matching formats returned + +## Common Pitfalls + +### Q: Why aren't my format filters working? + +**A:** Check that you're using structured `format_ids`, not strings: + +**Wrong:** +```json +{ + "filters": { + "format_ids": ["video_30s"] // ❌ Strings don't work + } +} +``` + +**Correct:** +```json +{ + "filters": { + "format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "video_30s_hosted" + } + ] + } +} +``` + +### Q: Why do my products not include `brief_relevance`? + +**A:** `brief_relevance` is only included when a `brief` parameter is provided. Standard catalog requests (no brief) don't include this field since products aren't contextually matched. + +### Q: Should I validate authorization in `get_products`? + +**A:** **Yes!** Buyer agents must validate sales agent authorization before purchasing: + +1. Get properties from products (or resolve `property_tags`) +2. Fetch `/.well-known/adagents.json` from each `publisher_domain` +3. Verify the sales agent URL appears in `authorized_agents` +4. Reject products from unauthorized agents + +See [Authorization Validation](/dist/docs/3.0.13/governance/property/adagents#buyer-agent-validation) for complete requirements. + +## Terminology + +### Q: What's the difference between "product" and "package"? + +**A:** +- **Product**: A sellable unit of inventory from the publisher (returned by `get_products`) +- **Package**: A buyer's selection from available products, sent in `create_media_buy` + +Products describe what's available. Packages describe what you're buying. + +### Q: What's the difference between "delivery" and "distribution"? + +**A:** +- **Delivery type**: `"guaranteed"` vs `"non_guaranteed"` (whether impressions are guaranteed) +- **Distribution**: How creatives are distributed to ad servers (covered by creative agents, not media buying) + +### Q: What's "TMP" / "Trusted Match Protocol"? + +**A:** The **[Trusted Match Protocol (TMP)](/dist/docs/3.0.13/trusted-match)** is AdCP's real-time execution layer. It determines which pre-negotiated packages activate at impression time using two structurally separated operations: **Context Match** (content relevance, no user identity) and **Identity Match** (user eligibility, no page context). The publisher joins both responses locally. TMP enables cross-publisher frequency capping, brand suitability, and audience targeting across web, mobile, CTV, AI assistants, and retail media. + +You may also see references to **AXE** (Agentic eXecution Engine) — that was TMP's predecessor. Existing AXE integrations still work, but new implementations should use TMP. See [AXE documentation](/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine) for legacy reference. + +## Need More Help? + +If your question isn't answered here: + +1. Check the [Task Reference](/dist/docs/3.0.13/media-buy/task-reference/) for detailed API documentation +2. Review [Brief Expectations](/dist/docs/3.0.13/media-buy/product-discovery/brief-expectations) for discovery guidance +3. See [Media Products](/dist/docs/3.0.13/media-buy/product-discovery/media-products) for product model details +4. Open a GitHub issue for specification clarifications + +This FAQ is updated regularly based on implementer feedback. diff --git a/dist/docs/3.0.13/reference/known-limitations.mdx b/dist/docs/3.0.13/reference/known-limitations.mdx new file mode 100644 index 0000000000..96a45a2da4 --- /dev/null +++ b/dist/docs/3.0.13/reference/known-limitations.mdx @@ -0,0 +1,80 @@ +--- +title: Known Limitations +sidebarTitle: Known Limitations +description: "Explicit non-goals and deferred items in AdCP 3.0 — what the protocol does not do, so implementers and reviewers can plan accordingly." +"og:title": "AdCP — Known Limitations" +--- + +Knowing what a protocol doesn't do is part of evaluating it. This page consolidates the explicit non-goals and deferred items in AdCP 3.0 — things the protocol intentionally does not handle, things that have been scoped out for this cycle, and things that exist as mechanisms but aren't enforced at the protocol layer. + +Each limitation below is either a visible edge of the specification or a tracked follow-up. None is hidden. + +Surfaces shipped in 3.x but not yet frozen are listed separately — see [Experimental Status](/dist/docs/3.0.13/reference/experimental-status). + +## Security and privacy + +- **No end-user authentication.** AdCP authenticates agents, not the humans they act for. User-level identity, consent capture, and data-subject rights are handled upstream in the buyer's stack and are outside the protocol's scope. +- **No protocol-level breach-notification SLA.** AdCP does not specify "seller MUST notify buyer within N hours of suspected key compromise." Notification commitments live in DPAs between parties. A future major version may tighten this. +- **No coordinated vulnerability disclosure baked into the protocol.** Individual agent operators should publish `/.well-known/security.txt`; there is no normative AdCP requirement yet. +- **No protocol-level PII transport.** The `hashed_email` and `hashed_phone` fields in `sync_audiences` require SHA-256 hashing on the buyer side — the schemas do not accept cleartext for those fields. Other identifier types exist for non-PII spaces. If you need cleartext PII on the wire, AdCP is not the right carrier. +- **Hashed identifiers are pseudonymous, not anonymous.** SHA-256 hashes of email and phone remain pseudonymous identifiers under GDPR and CPRA and inherit the regulatory treatment of the underlying PII. AdCP does not claim de-identification. +- **No protocol-level data-residency mechanism.** Residency is a configuration and contract property of individual agents; the protocol does not carry a normative residency tag. +- **No protocol guarantee about LLM prompt-injection defense.** That is an operator concern on every LLM-powered agent in the loop. See the [Security Model — threats specific to agentic advertising](/dist/docs/3.0.13/building/concepts/security-model#threats-specific-to-agentic-advertising). +- **Structural privacy applies only to TMP.** The Trusted Match Protocol enforces privacy structurally (separated code paths, schema prohibitions). Every other domain relies on contractual confidentiality or per-session consent. See [Privacy posture across domains](/dist/docs/3.0.13/protocol/architecture#privacy-posture-across-domains). +- **No jurisdictionally-aware consent signal on the wire.** AdCP does not carry a normative consent tag (e.g., IAB TCF, GPP, or equivalents used to express compliance with regimes like Quebec's Law 25, Japan's APPI, or Brazil's LGPD). Lawful-basis determination, consent capture, and jurisdictional routing remain the responsibility of each party acting as controller or processor in its own stack, governed by the DPAs between them. A structured cross-agent consent signal is a candidate for future work. +- **No consent-scope propagation across protocol boundaries.** A signal activated under one consent scope (e.g., `sync_audiences` from a CRM with first-party advertising consent) can be referenced downstream — re-targeted, attached to a different campaign, or composed with other signals — without a protocol-level mechanism enforcing scope alignment. Each party verifies scope compatibility off-protocol via DPAs and operational controls. Tracked for 3.1 in [#2540](https://github.com/adcontextprotocol/adcp/issues/2540). +- **No protocol-level cross-border transfer mechanism.** AdCP does not carry SCC, IDTA, or adequacy-decision metadata. International-transfer lawfulness is a contract and configuration property of the parties. +- **No versioned content-provenance chain.** AdCP carries right-use assertions and the `ai_generated_image` flag — the latter is a boolean marker, not a signed provenance assertion. The protocol does not specify a cryptographically signed provenance graph that accumulates assertions as a creative passes through generation, editing, and adaptation steps. Interoperation with emerging content-authenticity standards (CAI/C2PA) is tracked as future work. +- **No retention or deletion SLA.** The protocol does not specify how long parties retain `sync_audiences` inputs, `report_usage` records, or task history. Retention windows and data-subject-request fulfillment live in DPAs between the parties. + +## Commerce and settlement + +- **No in-protocol payment or settlement.** `report_usage` provides the consumption data that feeds invoicing, but invoicing and settlement happen out-of-band through the buyer/seller commercial relationship. +- **No cross-currency media buy.** Each media buy uses a single ISO 4217 currency (see `core/price.json`). If the buyer's budget currency differs from the seller's pricing currency, the buy either uses a matching currency or is rejected. FX rate pinning, risk attribution, and cross-currency reporting are deferred to a future version. +- **No protocol-level delivery-dispute flow.** When buyer and seller delivery numbers disagree, reconciliation happens out-of-band through the commercial relationship (backed by the audit trail on both sides). A structured dispute task is a candidate for future work. + +## Measurement and attribution + +- **Not an attribution protocol.** AdCP carries exposure records, identifiers where permitted, and the outcome signals that feed attribution, but it does not specify an attribution model. Media-mix modeling, multi-touch attribution, and incrementality testing live in the buyer's measurement stack — fed by AdCP data ([`report_usage`](/dist/docs/3.0.13/accounts/tasks/report_usage) and task-level outputs) rather than computed by the protocol. + +## Authentication and identity + +- **No OAuth 2.1 + resource-indicators normative requirement.** AdCP authenticates agents using mTLS, pre-provisioned API keys, and RFC 9421 signed HTTP requests (the last normative in 3.1). These are deliberately chosen for mutual authentication between autonomous agents rather than delegated human-user authorization. OAuth 2.1 with resource indicators is an acceptable transport where an operator's infrastructure already standardizes on it, but it is not a protocol requirement and does not substitute for the three mechanisms above. +- **Signed requests for mutating calls are normative in 3.1, not 3.0.** AdCP 3.0 allows bearer-token auth on mutating calls; 3.1 will require RFC 9421 signing or JWS-signed bodies. Tracked in [#2307](https://github.com/adcontextprotocol/adcp/issues/2307). +- **No key-transparency anchoring in the registry.** The [AgenticAdvertising.org registry](/dist/docs/3.0.13/registry/index) resolves brand identity, property authorization, and agent discovery, and can cache the `signing_keys[]` declared in a publisher's [`adagents.json`](/dist/docs/3.0.13/governance/property/adagents#signing_keys). What it does not yet do is operate as a key-transparency log: there is no enrollment ceremony binding a domain to a root verification key, no append-only rotation record, and no cryptographic commitment that every verifier sees the same key history. So in 3.x, RFC 9421 buyer keys, governance JWS keys, agent signing keys, and pointer files are still ultimately rooted in the counterparty's own infrastructure — an attacker who controls a counterparty's CDN, DNS, or `/.well-known` path can serve attacker-controlled keys, and TLS does not close this because the certificate is valid for the compromised hostname. 3.x delivers trust-on-first-use with continuity (multi-source cross-check, publication-delay windows, out-of-band rotation signalling, rotation-validity discipline) — detectably raising the bar, but not cryptographically closing the gap. The full close is a key-transparency layer on top of the existing registry, with append-only rotation logs and JWKS wire compatibility, tracked as a 4.0 deliverable. + +## Governance + +- **Regulated-category human review is enforced at the schema level for the three named categories, at the governance-agent level for everything else.** 3.0 rejects `authority_level: agent_full` at the schema level on campaigns that declare `fair_housing`, `fair_lending`, or `fair_employment` (shipped via [#2310](https://github.com/adcontextprotocol/adcp/issues/2310), merged 2026-04-18). Any other regulated category — political, pharmaceutical, gambling, alcohol, tobacco, financial, crypto, cannabis/CBD, firearms, dietary-supplement health claims, child-directed — relies on the governance-agent implementation rather than a schema invariant. +- **No protocol-mandated HITL on `sync_catalogs` or `sync_creatives`.** The universal task-lifecycle mechanism is available; no normative rule requires a human gate. `acquire_rights` is governed via the campaign-governance path ([purchase phase](/dist/docs/3.0.13/governance/campaign/specification#governance-phases)) when the buyer's plan is configured for it. See [How human-in-the-loop enters the protocol](/dist/docs/3.0.13/governance/embedded-human-judgment#how-human-in-the-loop-enters-the-protocol) for the two channels and the EHJ register for the normative rules on `check_governance`, `TERMS_REJECTED`, and lifecycle tasks. +- **Regulated categories beyond `fair_housing`, `fair_lending`, and `fair_employment` have no first-class treatment.** Political advertising, pharmaceutical, gambling, alcohol, tobacco, financial promotions (including crypto and digital assets), cannabis/CBD, firearms, dietary-supplement health claims, and advertising directed at children rely on the general [campaign-governance](/dist/docs/3.0.13/governance/campaign/specification) mechanism (HITL gates, governance tasks) rather than category-specific schema rules. Regionally specific disclosure requirements (e.g., UK FCA s.21, EU MiCA, political ad registries, COPPA, the UK Children's Code, GDPR Article 8) sit in the seller's delivery stack and the buyer's compliance posture, not the protocol. + +## Conformance and testing + +- **Reference test vectors are partial.** [Conformance](/dist/docs/3.0.13/building/verification/conformance) is defined by the storyboard suite, and the suite publishes request-signing and canonicalization vectors today — but a broader corpus of reference test vectors is tracked in [#2383](https://github.com/adcontextprotocol/adcp/issues/2383). +- **AdCP Verified is self-attested in 3.0; the formal program launches with 3.1.** Today, agents publish their own signed `runner-output.json` — reproducible and re-runnable by any verifying party, but not AAO-audited. The training agent and official SDKs are being brought to full storyboard compliance over the 3.0 → 3.1 window on a 4–6 week cadence (training agent is at 32/55 clean today). When they pass cleanly and the ambiguous-storyboard work is done, AAO will run submitted agents against the canonical storyboard suite and maintain a public registry of Verified agents. The compliance runner and storyboards are themselves software at 3.0 — storyboard bugs, coverage gaps, and encoded-spec-intent ambiguities are legitimate GitHub issues alongside implementation bugs. +- **No automated enforcement of platform agnosticism** beyond the `check:platform-agnostic` lint scanning property names. A richer check covering schema semantics is future work. +- **No latency or response-time SLA.** The protocol has no normative expectations for how quickly an agent must respond (unlike OpenRTB's per-auction `tmax`). Buyers and sellers negotiate timing through the commercial relationship or through task-specific [accountability terms](/dist/docs/3.0.13/media-buy/advanced-topics/accountability). A structured SLA declaration is deferred. + +## 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. +- **Endpoint hardening.** WAF, rate limiting, DDoS protection, TLS configuration, OS patching, dependency scanning. +- **Monitoring and incident response.** The protocol emits the signals worth watching (idempotency conflicts, governance failures, SSRF rejections). Detecting and responding to them is the operator's job. +- **Human controls.** Approval thresholds, spend caps, pause authority — these are policy configurations inside the operator's agent or 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. +- **Billing-grade metric accounting.** AdCP carries delivery and usage data end-to-end from the seller's ad delivery system, and [`report_usage`](/dist/docs/3.0.13/accounts/tasks/report_usage) feeds invoicing. The underlying delivery platform — or a buyer-specified measurement vendor — is the system of record for counting, audit, and MRC accreditation of the measurement methodology. AdCP itself is not MRC-accredited and does not seek accreditation; accreditation attaches to measurement systems, which sit downstream. AdCP is the wire and the contract, not the ledger. +- **Invalid-traffic filtration and viewability measurement.** Buyers and sellers agree on verification vendors, thresholds, and remediation through [accountability terms](/dist/docs/3.0.13/media-buy/advanced-topics/accountability). GIVT/SIVT filtration (per MRC), viewability measurement (per the MRC Viewable Ad Impression standard), and brand-safety verification execute in the delivery stack or the chosen vendor layer (e.g., DoubleVerify, IAS, HUMAN). +- **Accessibility conformance of served creatives.** WCAG, ADA, and EN 301 549 conformance of rendered ads sit with the creative supplier, the delivery stack, and the publisher's rendering context. AdCP does not carry accessibility-conformance assertions as normative fields. + +Think of AdCP as specifying the locks on the doors. The operator still owns the building. + +## Related + +- [Security Model](/dist/docs/3.0.13/building/concepts/security-model) — the threat model and layered defense +- [Privacy Considerations](/dist/docs/3.0.13/reference/privacy-considerations) — cross-protocol privacy entry point +- [Experimental Status](/dist/docs/3.0.13/reference/experimental-status) — surfaces shipped in 3.x but not yet frozen +- [Versioning & Governance](/dist/docs/3.0.13/reference/versioning) — cadence, support windows, and how limitations become follow-ups +- [Roadmap](/dist/docs/3.0.13/reference/roadmap) — what's coming next diff --git a/dist/docs/3.0.13/reference/media-channel-taxonomy.mdx b/dist/docs/3.0.13/reference/media-channel-taxonomy.mdx new file mode 100644 index 0000000000..0f4c955c45 --- /dev/null +++ b/dist/docs/3.0.13/reference/media-channel-taxonomy.mdx @@ -0,0 +1,792 @@ +--- +title: Media Channel Taxonomy +description: "AdCP media channel taxonomy: the 19 standardized channel types (CTV, OLV, DOOH, podcasts, retail media, etc.) used for media planning and budget allocation." +"og:title": "AdCP — Media Channel Taxonomy" +--- + +# Media Channel Taxonomy + + +**Status**: Draft Specification +**Version**: 1.0.0-draft +**Last Updated**: 2026-01-23 + + +This specification defines a standardized taxonomy for advertising media channels. It is designed for interoperability across media planning tools, ad tech platforms, and AI-powered advertising agents. + +## Motivation + +The advertising industry lacks a standardized channel taxonomy. While IAB Tech Lab provides taxonomies for content, audience, and ad products, no equivalent exists for media channels. This leads to: + +- Inconsistent channel naming across platforms +- Difficulty aggregating cross-channel campaign data +- Confusion between channels, formats, and buying models +- Friction in automated media planning workflows + +This specification addresses these gaps by defining: + +1. **Media Channels** - How buyers allocate budget (planning abstractions) +2. **Property Types** - Addressable inventory surfaces with verifiable ownership +3. **Clear distinction** between channels, property types, and formats + +## Design Philosophy + +**Channels represent how buyers plan and allocate budget**, not where ads technically render. + +This is a deliberate design choice. Buyers don't say "I have \$500K for web" — they say "I have \$500K for display" or "I have \$300K for OLV." The channel taxonomy reflects this reality. + +### Key Principles + +1. **Planning-oriented**: Channels match how agencies structure media plans +2. **Lightweight tags**: Channels help buyers express intent, not enforce precise classification +3. **Multi-channel support**: Properties may align with multiple channels (YouTube = `olv`, `social`, `ctv`) +4. **Publisher-declared**: Publishers indicate which channels their inventory supports +5. **Agent-reconciled**: Sales agents match buyer intent with publisher claims + +### Channels vs Technical Substrates + +Channels deliberately avoid substrate-level concepts like "web" or "mobile app" because: +- Buyers don't plan that way +- Most digital inventory would fall into both categories +- The same placement can be bought different ways + +Instead, channels describe **buying contexts**: display, OLV, social, search, CTV, etc. + +## Terminology + +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://datatracker.ietf.org/doc/html/rfc2119). + +## Definitions + +### Media Channel + +A **media channel** describes how buyers allocate budget. It represents a planning abstraction that encodes assumptions about audience, environment, and buying approach. + +Channels are defined by **buying context**, not by: +- Technical substrate (web vs app) +- How the ad looks (that's a **format**) +- The specific technology serving the ad + +### Property Type + +A **property type** describes a specific addressable inventory surface where: +- Ownership can be verified (e.g., via `adagents.json`) +- Ads can be programmatically served +- Identifiers exist for the property (domains, app IDs, device IDs) + +Property types are technical classifications, distinct from channels. The same property may support multiple channels. + +### Format Category + +A **format category** describes HOW an ad renders - its creative unit type. Examples: `video`, `audio`, `display`, `native`. Formats are orthogonal to channels. + +## Understanding Channels vs Property Types + +| Concept | Question It Answers | Determined By | Example | +|---------|---------------------|---------------|---------| +| **Channel** | How do buyers allocate budget? | Planning context | `retail_media` | +| **Property Type** | Where does the ad technically render? | Addressable inventory surface | `website` | + +### Why This Matters + +Consider retail media: when a buyer allocates budget to "retail media," they're buying: +1. Sponsored products on retailer websites +2. Display ads on retailer apps +3. Off-site ads using retailer data +4. In-store digital screens + +All of these are `retail_media` channel, but the property types vary (`website`, `mobile_app`, `dooh`). + + +Some names like `dooh`, `podcast`, `radio`, and `streaming_audio` appear as both a channel and a property type. The channel describes the **buying context** (how budget is allocated); the property type describes the **technical surface** (where the ad renders). For example, an in-store retail screen is `retail_media` channel / `dooh` property type, while a standalone digital billboard is `dooh` channel / `dooh` property type. + + +### Multi-Channel Properties + +Properties can align with multiple channels. Examples: + +| Property | Channels | Reasoning | +|----------|----------|-----------| +| YouTube | `olv`, `social`, `ctv` | Different buying contexts on same platform | +| ESPN App | `olv`, `display` | Supports video and display inventory | +| Amazon | `retail_media`, `search`, `display` | Multiple ad products | + +### When to Use Each + +**Use channel when:** +- Filtering products by budget allocation category +- Reporting on media mix +- Expressing buyer intent in product discovery + +**Use property_type when:** +- Validating inventory ownership via `adagents.json` +- Technical ad serving decisions +- Platform-specific targeting + +## Media Channels + +### Channel Enum + +The following 20 channels MUST be supported. Implementations MAY extend with additional channels using the `ext` field. + +| Channel | Description | +|---------|-------------| +| `display` | Digital display advertising (banners, native, rich media) | +| `olv` | Online video outside CTV (pre-roll, outstream, in-app video) | +| `social` | Social media platforms | +| `search` | Search engine advertising | +| `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 | +| `dooh` | Digital out-of-home screens | +| `ooh` | Classic out-of-home (billboards, transit) | +| `print` | Newspapers, magazines, print publications | +| `cinema` | Movie theater advertising | +| `email` | Email advertising and newsletters | +| `gaming` | In-game advertising | +| `retail_media` | Retail media networks and commerce marketplaces | +| `influencer` | Creator and influencer partnerships | +| `affiliate` | Affiliate networks and performance-based partnerships | +| `product_placement` | Product placement and branded content | +| `sponsored_intelligence` | Sponsored Intelligence — AI assistants, AI search, and generative AI platforms | + +### Channel Definitions + +#### `display` + +Digital display advertising including banners, native units, and rich media across web and app environments. + +**Includes**: +- Display banners on websites +- Display ads in mobile apps +- Native content units +- Rich media ads +- Interstitials (non-video) + +**Excludes**: +- Video ads (use `olv` or `ctv`) +- Social platform ads (use `social`) +- Search results (use `search`) +- Retail media placements (use `retail_media`) + +**Typical Formats**: display, native, rich_media + +#### `olv` + +Online video advertising delivered outside of CTV/television environments. + +**Includes**: +- Pre-roll, mid-roll, post-roll on websites +- In-app video ads +- Outstream/in-feed video +- YouTube video ads (when not on TV screens) +- Video on news sites, sports sites, etc. + +**Excludes**: +- CTV/streaming on TV screens (use `ctv`) +- Social platform video (use `social`) +- Linear TV (use `linear_tv`) +- Retail media video (use `retail_media`) + +**Typical Formats**: video + +**Note**: OLV (Online Video) is a distinct planning bucket from CTV. Agencies commonly budget separately for "OLV" and "CTV" campaigns. + +#### `social` + +Social media platform advertising, regardless of technical delivery surface. + +**Includes**: +- Meta platforms (Facebook, Instagram, Threads) +- X (Twitter) +- TikTok +- LinkedIn +- Snapchat +- Pinterest +- Reddit +- YouTube (when bought through social-style targeting) + +**Excludes**: +- Influencer content on social platforms (use `influencer`) +- Video ads bought for reach, not social engagement (consider `olv`) + +**Typical Formats**: display, video, native + +**Note**: Social is defined by BUYING CONTEXT (social platform ad tools) and audience engagement model. + +#### `search` + +Search engine results pages and search advertising networks. + +**Includes**: +- Google Search ads +- Microsoft Bing ads +- DuckDuckGo ads +- App store search ads +- Shopping/product listing ads in search context + +**Excludes**: +- Display ads on search engine properties (use `display`) +- Retail media search (use `retail_media`) + +**Typical Formats**: text, display (shopping) + +#### `ctv` + +Connected TV advertising delivered through streaming applications on television screens. + +**Includes**: +- Streaming service apps (Netflix, Hulu, Max, etc.) +- Virtual MVPDs (YouTube TV, Sling, etc.) +- Free ad-supported streaming TV (FAST) +- Smart TV native apps +- Gaming console streaming apps + +**Excludes**: +- Linear broadcast/cable (use `linear_tv`) +- Video on phones/tablets/desktops (use `olv`) +- YouTube on mobile (use `olv` or `social`) + +**Typical Formats**: video + +#### `linear_tv` + +Traditional broadcast and cable television advertising. + +**Includes**: +- National broadcast networks (ABC, CBS, NBC, Fox) +- Cable networks (ESPN, CNN, HGTV) +- Local broadcast stations +- Addressable linear TV + +**Excludes**: +- Streaming on TV screens (use `ctv`) +- TV Everywhere apps on mobile (use `olv`) + +**Typical Formats**: video + +#### `radio` + +Traditional AM/FM radio broadcast advertising. + +**Includes**: +- Terrestrial radio stations +- Satellite radio (SiriusXM terrestrial simulcast) +- HD Radio + +**Excludes**: +- Streaming audio services (use `streaming_audio`) +- Podcasts (use `podcast`) + +**Typical Formats**: audio + +#### `streaming_audio` + +Digital audio streaming services. + +**Includes**: +- Music streaming (Spotify, Apple Music, Amazon Music, Pandora) +- Audio content platforms +- Digital radio streams (iHeartRadio digital, TuneIn) + +**Excludes**: +- Podcasts (use `podcast`) +- Terrestrial radio simulcast (use `radio`) + +**Typical Formats**: audio, display (companion) + +#### `podcast` + +Podcast advertising, including host-read and dynamically inserted ads. + +**Includes**: +- Host-read sponsorships +- Dynamically inserted audio ads +- Podcast network advertising + +**Excludes**: +- Music streaming (use `streaming_audio`) +- Video podcasts on YouTube (use `olv` or `social`) + +**Typical Formats**: audio + +#### `dooh` + +Digital out-of-home advertising on electronic screens in public spaces. + +**Includes**: +- Digital billboards +- Transit screens (subway, bus shelters, airports) +- Retail/mall digital displays +- Gas station screens +- Elevator screens +- Stadium/venue digital signage + +**Excludes**: +- Static billboards (use `ooh`) +- Cinema screens (use `cinema`) + +**Typical Formats**: display, video + +#### `ooh` + +Classic out-of-home advertising on physical (non-digital) surfaces. + +**Includes**: +- Static billboards +- Transit posters +- Street furniture +- Wallscapes +- Wild postings + +**Excludes**: +- Digital screens (use `dooh`) + +**Typical Formats**: display (static) + +#### `print` + +Newspaper, magazine, and other print publication advertising. + +**Includes**: +- Newspaper display ads +- Magazine display ads +- Newspaper/magazine inserts +- Trade publication advertising + +**Excludes**: +- Digital versions of publications (use `display`) +- Direct mail + +**Typical Formats**: display (static) + +#### `cinema` + +Movie theater advertising. + +**Includes**: +- Pre-show advertising +- On-screen trailers and ads +- Lobby displays +- Concession advertising + +**Excludes**: +- Streaming movie services (use `ctv`) + +**Typical Formats**: video, display + +#### `email` + +Email advertising and sponsored newsletter content. + +**Includes**: +- Sponsored email newsletters +- Email display advertising +- Dedicated email sends + +**Excludes**: +- Transactional email +- CRM/owned email marketing + +**Typical Formats**: display, native + +#### `gaming` + +In-game advertising across gaming platforms. + +**Includes**: +- In-game display ads +- Rewarded video ads +- Playable ads +- Advergames +- Esports sponsorships +- Gaming influencer integrations + +**Excludes**: +- Ads in non-gaming apps (use `display` or `olv`) +- Gaming content on streaming platforms (use `ctv` or `olv`) + +**Typical Formats**: display, video, native + +#### `retail_media` + +Retail media networks and commerce marketplace advertising. + +**Includes**: +- Retail media networks (Amazon Ads, Walmart Connect, Target Roundel) +- Grocery and delivery platforms (Instacart, DoorDash, Uber) +- Travel marketplaces (Expedia, Booking.com, Kayak) +- Financial services marketplaces +- Sponsored product listings +- On-site display and video on commerce platforms +- Off-site ads using retailer first-party data + +**Excludes**: +- General display ads (use `display`) +- Social commerce (use `social`) +- Search ads on non-commerce platforms (use `search`) + +**Typical Formats**: native, display, video + +**Note**: Retail media is distinguished by its transactional context and closed-loop attribution capabilities. + +#### `influencer` + +Creator and influencer marketing partnerships. + +**Includes**: +- Sponsored content creation +- Brand ambassador programs +- Affiliate creator partnerships +- User-generated content campaigns + +**Excludes**: +- Ads placed on creator content by platforms (use `social`) +- Podcast host reads (use `podcast`) + +**Typical Formats**: video, native, display + +**Note**: `influencer` describes the BUYING MODEL (creator partnership) rather than where content appears. + +#### `affiliate` + +Affiliate networks, comparison sites, and performance-based publisher partnerships. + +**Includes**: +- Affiliate networks (CJ, Rakuten, Impact, Awin, ShareASale) +- Comparison shopping engines (NerdWallet, Bankrate, The Points Guy) +- Review and recommendation sites +- Coupon and deal sites (RetailMeNot, Honey) +- Content commerce (editorial with affiliate links) +- Lead generation sites +- Cashback and loyalty programs + +**Excludes**: +- Standard display ads on affiliate sites (use `display`) +- Influencer partnerships (use `influencer`) +- Retail media sponsored products (use `retail_media`) + +**Typical Formats**: native, display, text + +**Note**: `affiliate` describes the BUYING MODEL (performance-based, CPA/CPC/rev-share) rather than where content appears. + +#### `product_placement` + +Product placement, branded content, and sponsorship integrations. + +**Includes**: +- Traditional product placement (film, TV) +- Virtual product placement +- Branded entertainment +- Event sponsorships +- Naming rights +- Branded content series + +**Excludes**: +- Influencer partnerships (use `influencer`) +- Standard ad placements at sponsored events (use appropriate channel) + +**Typical Formats**: native, video + +#### `sponsored_intelligence` + +Sponsored Intelligence — advertising within AI assistants, AI search results, and generative AI experiences via the reversed data flow. + +**Includes**: +- Sponsored responses in AI assistants and chatbots +- Sponsored results in AI-powered search engines +- Display and video ads within generative AI experiences +- Brand experience handoffs via SI Chat Protocol + +**Excludes**: +- Standard search engine ads (use `search`) +- Display ads on AI company websites (use `display`) +- Social platform AI features (use `social`) + +**Typical Formats**: native, display, video + +**Note**: `sponsored_intelligence` describes the BUYING CONTEXT where the user is interacting with an AI system, not traditional web or app surfaces. The channel uses the reversed data flow — buyers push data in, platforms generate ads with full context. + +## Property Types + +Property types describe addressable inventory surfaces with verifiable ownership. They are used in `adagents.json` for authorization validation. + +### Property Type Enum + +| Property Type | Description | Example Channels | +|---------------|-------------|------------------| +| `website` | Web properties accessible via browser | `display`, `olv` | +| `mobile_app` | Native mobile applications | `display`, `olv`, `social`, `gaming` | +| `ctv_app` | Connected TV applications | `ctv` | +| `desktop_app` | Desktop applications (Electron, native) | `streaming_audio`, `gaming` | +| `dooh` | Digital out-of-home screen networks | `dooh` | +| `podcast` | Podcast feeds and episodes | `podcast` | +| `radio` | Radio station properties | `radio` | +| `linear_tv` | Linear television stations and networks | `linear_tv` | +| `streaming_audio` | Digital audio streaming properties | `streaming_audio` | +| `ai_assistant` | AI-powered conversational interfaces | `sponsored_intelligence` | + +### Channels Without Property Types + +The following channels do not have corresponding property types because they lack addressable, verifiable inventory surfaces in the traditional sense: + +- `ooh` - Physical inventory lacks digital identifiers +- `print` - Physical publication inventory +- `cinema` - Theater inventory management systems +- `email` - Email list ownership differs from property authorization +- `influencer` - Creator relationships rather than property ownership +- `affiliate` - Performance-based partnerships, placements appear on partner websites +- `product_placement` - Content/event relationships rather than properties +- `retail_media` - Platform-managed inventory within retail ecosystems +- `search` - Platform-managed inventory +- `social` - Platform-managed inventory + +## Relationship Between Concepts + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MEDIA CHANNEL │ +│ How buyers allocate budget (planning abstraction) │ +│ │ +│ display, olv, social, search, ctv, linear_tv, podcast, │ +│ streaming_audio, radio, dooh, ooh, print, cinema, email, │ +│ gaming, retail_media, influencer, affiliate, │ +│ product_placement, sponsored_intelligence │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ PROPERTY TYPE │ +│ Addressable inventory with verifiable ownership (technical) │ +│ │ +│ website, mobile_app, ctv_app, desktop_app, dooh, │ +│ podcast, radio, linear_tv, streaming_audio, ai_assistant │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ FORMAT CATEGORY │ +│ How the ad renders (orthogonal to channel) │ +│ │ +│ audio, video, display, native, rich_media, text │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Implementation + +### JSON Schema + +Channels are defined in the AdCP schema at: + +``` +/schemas/v3/enums/channels.json +``` + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/channels.json", + "title": "Media Channel", + "description": "Standardized advertising media channels describing how buyers allocate budget", + "type": "string", + "enum": [ + "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" + ] +} +``` + +### Usage in Product Discovery + +When filtering products by channel: + +```json +{ + "filters": { + "channels": ["ctv", "olv", "streaming_audio"] + } +} +``` + +### Usage in Property Definitions + +Properties in `adagents.json` declare which channels they support: + +```json +{ + "properties": [ + { + "property_id": "youtube_app", + "property_type": "ctv_app", + "name": "YouTube CTV", + "supported_channels": ["ctv", "olv", "social"], + "identifiers": [ + {"type": "roku_store_id", "value": "12345"} + ] + } + ] +} +``` + +### Usage in Product Definitions + +Products declare which channels they are sold as. This typically inherits from properties but may be more specific: + +```json +{ + "product_id": "youtube_ctv_premium", + "name": "YouTube CTV Premium", + "channels": ["ctv"], + "publisher_properties": [ + { + "publisher_domain": "youtube.com", + "selection_type": "by_tag", + "property_tags": ["ctv_apps"] + } + ] +} +``` + +Even though YouTube properties support `["olv", "social", "ctv"]`, this product is specifically sold as CTV inventory. + +### Extensibility + +Implementations MAY support additional channels beyond this specification using the `ext` field pattern: + +```json +{ + "channel": "display", + "ext": { + "sub_channel": "native_content" + } +} +``` + +## Versioning + +This taxonomy follows [Semantic Versioning](https://semver.org/): + +- **MAJOR**: Removing channels, changing channel semantics +- **MINOR**: Adding new channels (append-only) +- **PATCH**: Clarifying descriptions, fixing typos + +Adding new channels is a MINOR version change and MUST NOT break existing implementations. + +## Migration from Legacy Channel Values + +Prior to this taxonomy, AdCP used a different set of channel values. The following table maps legacy values to the new taxonomy: + +| Legacy Value | New Channel(s) | Notes | +|--------------|----------------|-------| +| `display` | `display` | No change, but now excludes video | +| `video` | `olv`, `ctv` | Split by viewing environment | +| `audio` | `streaming_audio`, `podcast`, `radio` | Split by audio type | +| `native` | `display` | Native is a format, not a channel | +| `web` | `display`, `olv` | Web is a substrate, not a planning bucket | +| `mobile_app` | `display`, `olv`, `gaming` | App is a substrate, not a planning bucket | +| `dooh` | `dooh` | No change | +| `ctv` | `ctv` | No change | +| `podcast` | `podcast` | No change | +| `retail` | `retail_media` | Renamed | +| `commerce_media` | `retail_media` | Renamed | +| `social` | `social` | No change | +| `sponsorship` | `product_placement` | Renamed and refined | + +### Migration Guidance + +1. **Identify the planning context**: Determine HOW the buyer allocates budget, not where ads technically render. + +2. **Split video by environment**: If video was a single category, split into `olv` (desktop/mobile) and `ctv` (TV screens). + +3. **Remove substrate channels**: If you had `web` or `mobile_app` as channels, map to planning-oriented channels (`display`, `olv`) based on buying context. + +4. **Update filters**: When filtering products, use the new channel values. + +## Edge Cases and Ambiguities + +### YouTube Classification + +YouTube spans multiple channels depending on context: + +| Scenario | Channel | Reasoning | +|----------|---------|-----------| +| YouTube video ad on phone/desktop | `olv` or `social` | Depends on buying approach | +| YouTube video ad on CTV | `ctv` | TV screen environment | +| YouTube Shorts | `social` | Short-form social context | +| YouTube Music | `streaming_audio` | Audio streaming context | + +### Retail Media Complexity + +Retail media may eventually warrant sub-channels: + +| Scenario | Current | Potential Future | +|----------|---------|------------------| +| Sponsored products on Amazon | `retail_media` | `retail_media_search` | +| Display on retailer site | `retail_media` | `retail_media_display` | +| Off-site using retailer data | `retail_media` | `retail_media_offsite` | + +With `channels` on products, buyers can distinguish retail media product types (sponsored products vs display vs offsite) without sub-channels. Use `channels: ["retail_media"]` combined with `format_ids` to narrow results. The two-axis approach (channel + format) is preferred over channel proliferation. + +### Gaming vs Display/OLV + +| Scenario | Channel | Reasoning | +|----------|---------|-----------| +| Rewarded video in mobile game via Unity Ads | `gaming` | Gaming-specific ad network | +| Banner in casual game via AdMob general pool | `display` | Standard mobile programmatic | +| Esports tournament sponsorship | `gaming` | Gaming audience context | + +### Influencer vs Social + +| Scenario | Channel | Reasoning | +|----------|---------|-----------| +| Buying promoted posts through Instagram Ads | `social` | Platform ad tools | +| Contracting an influencer directly | `influencer` | Creator partnership | +| Platform-inserted ads around creator content | `social` | Platform ad tools | + +## Future Considerations + +The following channels may be added in future versions based on market evolution: + +- `messaging` - WhatsApp Business, Telegram ads +- `xr` - VR/AR advertising + +## References + +- [IAB Tech Lab Taxonomies](https://github.com/InteractiveAdvertisingBureau/Taxonomies) - Content, Audience, Ad Product +- [OpenRTB/AdCOM](https://github.com/InteractiveAdvertisingBureau/AdCOM) - Placement types and media objects +- [Planmatic Media Plan Schema](https://github.com/planmatic/mediaplanschema) - Media planning data structures +- [RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) - Requirement level keywords + +## Changelog + +### 1.1.0-draft (2026-03-13) + +- Rename `ai_media` to `sponsored_intelligence` — Sponsored Intelligence is the umbrella for AI platform advertising (AI assistants, AI search, generative AI experiences) +- 20 channels defined + +### 1.0.0-draft (2026-01-23) + +- Initial draft specification +- 19 channels defined (planning-oriented approach) +- 10 property types defined +- Clear distinction between channel (planning abstraction), property_type (technical surface), and format +- Multi-channel support for properties +- Migration guide from legacy values diff --git a/dist/docs/3.0.13/reference/migration/attribution.mdx b/dist/docs/3.0.13/reference/migration/attribution.mdx new file mode 100644 index 0000000000..c0ddbbdcf0 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/attribution.mdx @@ -0,0 +1,104 @@ +--- +title: "Migrating attribution windows" +description: "Migrate AdCP attribution windows from v2 to v3. Replaces integer day counts with structured Duration objects and adds required attribution model field." +"og:title": "AdCP — Migrating attribution windows" +testable: true +--- + +# 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 +{ + "attribution_window": { + "click_window_days": 7, + "view_window_days": 1, + "model": "last_touch" + } +} +``` + +### After (v3) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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.0.13/reference/migration/channels) | [Pricing](/dist/docs/3.0.13/reference/migration/pricing) | [Geo targeting](/dist/docs/3.0.13/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.0.13/reference/migration/creatives) | [Catalogs](/dist/docs/3.0.13/reference/migration/catalogs) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/audiences.mdx b/dist/docs/3.0.13/reference/migration/audiences.mdx new file mode 100644 index 0000000000..aa562c4392 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/audiences.mdx @@ -0,0 +1,136 @@ +--- +title: "Migrating audiences" +description: "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." +"og:title": "AdCP — Migrating audiences" +testable: true +--- + +# 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/reference/migration/brand-identity) | [Optimization goals](/dist/docs/3.0.13/reference/migration/optimization-goals) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/brand-identity.mdx b/dist/docs/3.0.13/reference/migration/brand-identity.mdx new file mode 100644 index 0000000000..5d37d3a9bd --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/brand-identity.mdx @@ -0,0 +1,125 @@ +--- +title: "Migrating brand identity" +description: "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." +"og:title": "AdCP — Migrating brand identity" +testable: true +--- + +# 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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 +{ + "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.0.13/reference/migration/channels) | [Catalogs](/dist/docs/3.0.13/reference/migration/catalogs) | [Brand Protocol](/dist/docs/3.0.13/brand-protocol) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/catalogs.mdx b/dist/docs/3.0.13/reference/migration/catalogs.mdx new file mode 100644 index 0000000000..298b383f61 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/catalogs.mdx @@ -0,0 +1,366 @@ +--- +title: "Migrating catalogs" +description: "Migrate AdCP catalogs from v2 to v3. Replaces promoted_offerings with first-class catalog objects, sync_catalogs task, and format-level catalog requirements." +"og:title": "AdCP — Migrating catalogs" +--- + +# 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 +{ + "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 +{ + "$schema": "/schemas/3.0.13/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 +{ + "media_buy_id": "mb_123", + "promoted_offering": "Winter Sale Collection", + "status": "draft", + "total_budget": { + "amount": 10000, + "currency": "USD" + }, + "packages": [] +} +``` + +### After (v3) + +```json +{ + "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 +{ + "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 +// 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 +// 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 +{ + "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 +{ + "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 +{ + "brand": { "domain": "acmecorp.com" }, + "product_selectors": { + "manifest_gtins": ["00013000006040"], + "manifest_tags": ["organic"] + } +} +``` + +### After + +```json +{ + "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.0.13/reference/migration/channels) | [Pricing](/dist/docs/3.0.13/reference/migration/pricing) | [Geo targeting](/dist/docs/3.0.13/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.0.13/reference/migration/creatives) | [Attribution](/dist/docs/3.0.13/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/channels.mdx b/dist/docs/3.0.13/reference/migration/channels.mdx new file mode 100644 index 0000000000..ee81d52f8c --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/channels.mdx @@ -0,0 +1,165 @@ +--- +title: "Migrating channels" +description: "Migrate AdCP channels from v2 to v3. Maps v2's 9 format-oriented channels to v3's 19 planning-oriented media channels with field-by-field examples." +"og:title": "AdCP — Migrating channels" +testable: true +--- + +# Migrating channels + +AdCP 3.0 replaces v2's 9 channels with 19 planning-oriented channels that reflect how buyers allocate budgets. Five channels carry over unchanged, while the rest are split, removed, or renamed. + +## 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 19 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 +``` + +| 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 | + + +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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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 +{ + "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 19 v3 channels. + + +--- + +**Related:** [Pricing](/dist/docs/3.0.13/reference/migration/pricing) | [Geo targeting](/dist/docs/3.0.13/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.0.13/reference/migration/creatives) | [Catalogs](/dist/docs/3.0.13/reference/migration/catalogs) | [Attribution](/dist/docs/3.0.13/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/creatives.mdx b/dist/docs/3.0.13/reference/migration/creatives.mdx new file mode 100644 index 0000000000..a8e53a20fd --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/creatives.mdx @@ -0,0 +1,368 @@ +--- +title: "Migrating creatives" +description: "Migrate AdCP creatives from v2 to v3. Replaces creative_ids with weighted assignments and assets_required with a unified assets array for format discovery." +"og:title": "AdCP — Migrating creatives" +testable: true +--- + +# 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 +{ + "format_types": ["video"] +} +``` + +**v3 — filter by asset type:** +```json +{ + "asset_types": ["video"] +} +``` + +**v3 — filter by exact format ID:** +```json +{ + "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 +{ + "creative_ids": ["creative_1", "creative_2"] +} +``` + +**v3 — omit `weight` for equal distribution:** +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/core/creative-assignment.json", + "creative_id": "creative_1" +} +``` + +In context of a `create_media_buy` package: + +```json +{ + "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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "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": { + "url_type": "tracking_pixel" + } + } + ] +} +``` + +### 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 +{ + "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 +{ + "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 +{ + "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.0.13/reference/migration/channels) | [Pricing](/dist/docs/3.0.13/reference/migration/pricing) | [Geo targeting](/dist/docs/3.0.13/reference/migration/geo-targeting) | [Catalogs](/dist/docs/3.0.13/reference/migration/catalogs) | [Attribution](/dist/docs/3.0.13/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/geo-targeting.mdx b/dist/docs/3.0.13/reference/migration/geo-targeting.mdx new file mode 100644 index 0000000000..a6c46f062f --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/geo-targeting.mdx @@ -0,0 +1,204 @@ +--- +title: "Migrating geo targeting" +description: "Migrate AdCP geo targeting from v2 to v3. Replaces implicit US-centric targeting with named systems (Nielsen DMA, postal codes) for global market support." +"og:title": "AdCP — Migrating geo targeting" +testable: true +--- + +# Migrating geo targeting + +AdCP 3.0 replaces implicit US-centric geo targeting with named systems that support global markets. Metro and postal targeting now require explicit system specification, and codes are grouped by 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` (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 +{ + "targeting": { + "geo_metros": ["501", "602"] + } +} +``` + +**v3** — codes grouped by system: +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 | + +Supported systems are defined in the `metro-system.json` enum: `nielsen_dma`, `uk_itl1`, `uk_itl2`, `eurostat_nuts2`, `custom`. + +## Postal targeting + +**v2** — flat array of codes, assumed to be US ZIP codes: +```json +{ + "targeting": { + "geo_postal_codes": ["10001", "90210"] + } +} +``` + +**v3** — renamed to `geo_postal_areas`, codes grouped by system: +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/core/targeting.json", + "geo_postal_areas": [ + { "system": "us_zip", "values": ["10001", "90210"] } + ] +} +``` + +### Postal systems + +| System | Coverage | Precision | Example codes | +|--------|----------|-----------|---------------| +| `us_zip` | US | 5-digit ZIP | `10001`, `90210` | +| `us_zip_plus_four` | US | ZIP+4 | `10001-1234` | +| `gb_outward` | UK | Area-level | `SW1`, `EC2` | +| `gb_full` | UK | Full postcode | `SW1A 1AA` | +| `ca_fsa` | Canada | Forward sortation area | `M5V`, `V6B` | +| `ca_full` | Canada | Full postal code | `M5V 2T6` | +| `de_plz` | Germany | Postleitzahl | `10115`, `80331` | +| `fr_code_postal` | France | Code postal | `75001`, `13001` | +| `au_postcode` | Australia | Postcode | `2000`, `3000` | +| `ch_plz` | Switzerland | Postleitzahl | `8000`, `3000` | +| `at_plz` | Austria | Postleitzahl | `1010`, `6020` | + +Supported systems are defined in the `postal-system.json` enum: `us_zip`, `us_zip_plus_four`, `gb_outward`, `gb_full`, `ca_fsa`, `ca_full`, `de_plz`, `fr_code_postal`, `au_postcode`, `ch_plz`, `at_plz`. + +## Exclusion targeting + +v3 adds `_exclude` variants for metro and postal targeting: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "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 + } + } + } + } +} +``` + +The `geo_metros` and `geo_postal_areas` objects use boolean properties to indicate which systems are supported. 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/core/targeting.json", + "geo_countries": ["US", "CA"], + "geo_regions": ["US-NY", "US-CA"], + "geo_metros": [ + { "system": "nielsen_dma", "values": ["501", "803"] } + ], + "geo_postal_areas": [ + { "system": "us_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 flat arrays into `{ "system": "...", "values": [...] }` objects. + + + For US-only code, use `nielsen_dma` and `us_zip`. For international, add the appropriate 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.0.13/reference/migration/channels) | [Pricing](/dist/docs/3.0.13/reference/migration/pricing) | [Creatives](/dist/docs/3.0.13/reference/migration/creatives) | [Catalogs](/dist/docs/3.0.13/reference/migration/catalogs) | [Attribution](/dist/docs/3.0.13/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/index.mdx b/dist/docs/3.0.13/reference/migration/index.mdx new file mode 100644 index 0000000000..88514fb57a --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/index.mdx @@ -0,0 +1,124 @@ +--- +title: Migrating from v2 to v3 +sidebarTitle: Migration guide +description: "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." +"og:title": "AdCP — Migrating from v2 to v3" +--- + +# 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.0.13/reference/whats-new-in-v3); for release-candidate deltas see [prerelease upgrade notes](/dist/docs/3.0.13/reference/migration/prerelease-upgrades); for SDK versions that support 3.0 see [Schemas and SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas#adcp-3-0-support). + + +**v2 is unsupported as of 3.0 GA and fully deprecated on August 1, 2026 (UTC).** See the [v2 sunset page](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/reference/migration/channels) | +| Channels | `video` split into `olv`, `linear_tv`, `cinema` | Restructure | [Channels migration](/dist/docs/3.0.13/reference/migration/channels) | +| Channels | 10 new channels added (including `sponsored_intelligence`) | Rename | [Channels migration](/dist/docs/3.0.13/reference/migration/channels) | +| Pricing | `fixed_rate` → `fixed_price` | Rename | [Pricing migration](/dist/docs/3.0.13/reference/migration/pricing) | +| Pricing | `price_guidance.floor` → `floor_price` | Rename | [Pricing migration](/dist/docs/3.0.13/reference/migration/pricing) | +| Pricing | Price guidance restructured | Restructure | [Pricing migration](/dist/docs/3.0.13/reference/migration/pricing) | +| Creatives | `creative_ids` → `creative_assignments` with weights | Restructure | [Creatives migration](/dist/docs/3.0.13/reference/migration/creatives) | +| Creatives | Asset discovery via `assets` array | New requirement | [Creatives migration](/dist/docs/3.0.13/reference/migration/creatives) | +| Catalogs | `promoted_offerings` → `sync_catalogs` | Restructure | [Catalogs migration](/dist/docs/3.0.13/reference/migration/catalogs) | +| Geo targeting | Flat arrays → system specification required | Restructure | [Geo targeting migration](/dist/docs/3.0.13/reference/migration/geo-targeting) | +| Optimization | `optimization_goal` (single) → `optimization_goals` (array, discriminated union) | Restructure | [Optimization goals migration](/dist/docs/3.0.13/reference/migration/optimization-goals) | +| Brand identity | `brand_manifest` → `brand` ref (`{ domain, brand_id }`) | Restructure | [Brand identity migration](/dist/docs/3.0.13/reference/migration/brand-identity) | +| Capability discovery | `adcp-extension.json` → `get_adcp_capabilities` | Restructure | [Capability discovery](/dist/docs/3.0.13/protocol/get_adcp_capabilities) | +| Signals | Delivery flattening, pricing restructure | Restructure | [Signals migration](/dist/docs/3.0.13/reference/migration/signals) | +| Audiences | `external_id` promoted to required top-level field | Restructure | [Audiences migration](/dist/docs/3.0.13/reference/migration/audiences) | +| Attribution | Integer day counts → `Duration` objects | Restructure | [Attribution migration](/dist/docs/3.0.13/reference/migration/attribution) | +| Products | `buying_mode` required on every `get_products` request | New requirement | [get_products reference](/dist/docs/3.0.13/media-buy/task-reference/get_products) | +| Media buy status | `pending_activation` → `pending_start` | Rename | [Media buys](/dist/docs/3.0.13/media-buy/media-buys) | +| Media buy status | `pending_creatives` added (no creatives assigned yet) | New requirement | [Media buys](/dist/docs/3.0.13/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.0.13/building/by-layer/L3/task-lifecycle) | +| Capabilities | `compliance_testing` capability block on `get_adcp_capabilities` | Additive | [Capability discovery](/dist/docs/3.0.13/protocol/get_adcp_capabilities#compliance_testing) | +| Idempotency | `idempotency_key` required on all mutating requests (UUID v4) | New requirement | [Security § Idempotency](/dist/docs/3.0.13/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.0.13/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.0.13/building/by-layer/L3/webhooks) | +| Webhook idempotency | `idempotency_key` required on every webhook payload | New requirement | [Webhooks § Reliability](/dist/docs/3.0.13/building/by-layer/L3/webhooks) | +| Governance | `governance_context` is a signed JWS verified via governance agent JWKS | Restructure | [Governance](/dist/docs/3.0.13/governance/campaign) | +| IO approval | `MediaBuy.pending_approval` removed — approval is a task-layer object | Restructure | [Media buy lifecycle](/dist/docs/3.0.13/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.0.13/governance/annex-iii-obligations) | + + +`buying_mode` on `get_products` is checked by storyboard testing. Sellers must handle all three buying modes (`browse`, `brief`, `refine`). See the [v3 readiness checklist](/dist/docs/3.0.13/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` (implicit accounts), `list_accounts` when `true` (explicit accounts) | All buyers — the seller's `require_operator_auth` flag 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.0.13/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.0.13/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. + +## Deep-dive pages + + + + `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 + + diff --git a/dist/docs/3.0.13/reference/migration/media-buy-status.mdx b/dist/docs/3.0.13/reference/migration/media-buy-status.mdx new file mode 100644 index 0000000000..1aacff8269 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/media-buy-status.mdx @@ -0,0 +1,92 @@ +--- +title: "Migrating to `media_buy_status` (3.1)" +description: "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." +"og:title": "AdCP — Migrating to media_buy_status" +--- + +# 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 +{ + "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 +{ + "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 +// 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.0.13/media-buy/task-reference/create_media_buy) — canonical response examples +- [Media buy lifecycle](/dist/docs/3.0.13/media-buy/media-buys/lifecycle) — the MediaBuyStatus state machine +- [Envelope task-status](/dist/docs/3.0.13/building/by-layer/L3/task-lifecycle) — TaskStatus semantics diff --git a/dist/docs/3.0.13/reference/migration/optimization-goals.mdx b/dist/docs/3.0.13/reference/migration/optimization-goals.mdx new file mode 100644 index 0000000000..c2ab0161e1 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/optimization-goals.mdx @@ -0,0 +1,193 @@ +--- +title: "Migrating optimization goals" +description: "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." +"og:title": "AdCP — Migrating optimization goals" +testable: true +--- + +# 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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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`, `attention_seconds`, `attention_score`, `engagements`, `follows`, `saves`, `profile_visits`, `reach`. + +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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "optimization_goal": { + "metric": "clicks", + "target_cpc": 2.50 + } +} +``` + +**rc.1:** +```json +{ + "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 +{ + "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.0.13/reference/migration/channels) | [Pricing](/dist/docs/3.0.13/reference/migration/pricing) | [Signals](/dist/docs/3.0.13/reference/migration/signals) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/prerelease-upgrades.mdx b/dist/docs/3.0.13/reference/migration/prerelease-upgrades.mdx new file mode 100644 index 0000000000..c25dbfc728 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/prerelease-upgrades.mdx @@ -0,0 +1,227 @@ +--- +title: Prerelease upgrade notes +sidebarTitle: Prerelease upgrades +description: "Breaking and additive changes between AdCP 3.0.0 release candidates." +"og:title": "AdCP — Prerelease upgrade notes" +--- + +# 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.0.13/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.0.13/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 the webhook-signing JWK in your JWKS at `jwks_uri` (referenced from `brand.json` `agents[]`). Set `adcp_use: "webhook-signing"` on the JWK itself (NOT as a field on the `agents[]` entry), and keep `kid` unique across purposes within the JWKS. 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.0.13/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.0.13/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 +{ + "features": { + "content_standards": true, + "audience_targeting": true + }, + "trusted_match": { + "supported": true, + "uid_types": ["email_sha256"] + } +} +``` + +**After (3.0):** +```json +{ + "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 +{ + "creative_manifest": { "format_id": { ... }, "assets": { ... } } +} +``` + +**After (3.0):** +```json +{ + "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 +{ + "media_buy_id": "mb_456", + "planned_delivery": { ... } +} +``` + +**After (3.0):** +```json +{ + "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 webhook-signing JWK in their JWKS at `jwks_uri` with `adcp_use: "webhook-signing"` on the JWK, and keep `kid` unique across purposes in the JWKS. 14-step webhook verifier checklist in the [Security guide](/dist/docs/3.0.13/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/implementation/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.0.13/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 implicit 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.0.13/reference/migration) diff --git a/dist/docs/3.0.13/reference/migration/pricing.mdx b/dist/docs/3.0.13/reference/migration/pricing.mdx new file mode 100644 index 0000000000..23c07826b0 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/pricing.mdx @@ -0,0 +1,227 @@ +--- +title: "Migrating pricing" +description: "Migrate AdCP pricing from v2 to v3. Covers field renames, the hard constraint vs soft hint separation, and updated pricing option schemas." +"og:title": "AdCP — Migrating pricing" +testable: true +--- + +# 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 +{ + "pricing_option_id": "cpm_usd_fixed", + "pricing_model": "cpm", + "currency": "USD", + "fixed_rate": 25.00 +} +``` + +**v3:** +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "pricing_option_id": "cpm_usd_auction", + "pricing_model": "cpm", + "currency": "USD", + "price_guidance": { + "floor": 10.00, + "p50": 15.00, + "p75": 18.00 + } +} +``` + +**v3:** +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +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.0.13/pricing-options/`. + + + + + How products declare pricing options, channels, and capabilities. + + +--- + +**Related:** [Channels](/dist/docs/3.0.13/reference/migration/channels) | [Geo targeting](/dist/docs/3.0.13/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.0.13/reference/migration/creatives) | [Catalogs](/dist/docs/3.0.13/reference/migration/catalogs) | [Attribution](/dist/docs/3.0.13/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/signals.mdx b/dist/docs/3.0.13/reference/migration/signals.mdx new file mode 100644 index 0000000000..52d11e25a8 --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/signals.mdx @@ -0,0 +1,177 @@ +--- +title: "Migrating signals" +description: "Migrate AdCP signals from beta.3 to rc.1. Covers deliver_to flattening, structured pricing options, and simplified usage reporting fields." +"og:title": "AdCP — Migrating signals" +testable: true +--- + +# 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 +{ + "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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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 +{ + "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 +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/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.0.13/reference/migration/pricing) | [Optimization goals](/dist/docs/3.0.13/reference/migration/optimization-goals) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) diff --git a/dist/docs/3.0.13/reference/migration/v3-readiness.mdx b/dist/docs/3.0.13/reference/migration/v3-readiness.mdx new file mode 100644 index 0000000000..d54abf892e --- /dev/null +++ b/dist/docs/3.0.13/reference/migration/v3-readiness.mdx @@ -0,0 +1,162 @@ +--- +title: "v3 readiness checklist" +sidebarTitle: Readiness checklist +description: "The 8 minimum requirements for seller agents to pass AdCP v3 storyboard testing." +"og:title": "AdCP — v3 readiness checklist" +--- + +# 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.0.13/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.0.13/reference/migration). v2 is fully deprecated on August 1, 2026 (UTC) — see the [v2 sunset page](/dist/docs/3.0.13/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 +// 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 +// 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. Your agent must accept and handle it. The three modes are `browse`, `brief`, and `refine`. + + + 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.0.13/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. + +| 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 +// 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": "..." +} +``` + +--- + +## 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.0.13/reference/migration). diff --git a/dist/docs/3.0.13/reference/privacy-considerations.mdx b/dist/docs/3.0.13/reference/privacy-considerations.mdx new file mode 100644 index 0000000000..9cea8b51c7 --- /dev/null +++ b/dist/docs/3.0.13/reference/privacy-considerations.mdx @@ -0,0 +1,125 @@ +--- +title: Privacy Considerations +sidebarTitle: Privacy Considerations +description: "Cross-protocol privacy entry point for AdCP implementers, compliance reviewers, and CISOs — what each protocol carries, what it does not, and where implementers must handle privacy themselves." +"og:title": "AdCP — Privacy Considerations" +--- + +This page is the cross-protocol entry point for privacy in AdCP. It names the categories implementers and compliance reviewers need to think about, summarizes what each AdCP protocol carries and does not carry, and links to the deeper references. It does not replace any of them. + +If you need the deep architectural picture of a specific domain, follow the links. AdCP does not publish a Privacy Impact Assessment template; see [For DPOs and procurement reviewers](#for-dpos-and-procurement-reviewers) below for the inputs a deployer's DPO needs to assemble their own assessment. + +## What AdCP's privacy posture is — and isn't + +AdCP specifies a wire protocol between agents. It does not specify: + +- End-user authentication or consent capture (handled upstream) +- Data-subject rights workflows (the buyer's responsibility) +- Data residency (a configuration and contract property of individual agents) +- Retention policy (the operator's responsibility) + +What AdCP does specify is the *shape* of what each protocol carries, the prohibitions on what it must not carry, and the structural separations that apply where the protocol is privacy-sensitive. Implementers are responsible for privacy controls at every boundary the protocol does not itself enforce. + +## Privacy posture by domain + +AdCP's privacy guarantees are not uniform across protocols. See [Privacy posture across domains](/dist/docs/3.0.13/protocol/architecture#privacy-posture-across-domains) for the full table. Summary: + +- **Trusted Match Protocol (TMP)** — **structural separation**. Context Match and Identity Match run on separated code paths; schemas prohibit crossover; TEE attestation (when deployed) makes the separation independently verifiable. See [TMP privacy architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture). +- **Media Buy, Creative, Signals, Governance** — **contractual confidentiality**. The parties exchanging data are bound by the account's terms, not by protocol-level separation. +- **Sponsored Intelligence** — **per-session consent**. The user consents per session; networks that route sessions may see routing metadata. See [SI networks](/dist/docs/3.0.13/sponsored-intelligence/networks). +- **Brand / Registry** — **public by design**. `brand.json` is discoverable at `/.well-known/brand.json`; the registry exposes public entity resolution. + +Governance gating (via campaign governance) operates independently of privacy posture: it can require human approval on budget, policy, or brand-safety grounds, but it does not change what data the underlying domain carries. + +## Privacy categories to think about + +### Data minimization + +AdCP minimizes the data that crosses protocol boundaries where it can: + +- When using `hashed_email` or `hashed_phone` in `sync_audiences`, buyers MUST SHA-256 hash normalized values before transport — the schemas for those fields do not accept cleartext. Non-hashed identifier types exist for other spaces; implementers MUST choose a hashed type when transporting email or phone. +- TMP Context Match carries no user identity; TMP Identity Match carries no page context. Both are enforced at the schema level. +- `governance_context` tokens can use `policy_decision_hash` instead of inline decisions when the buyer's compliance posture is sensitive. + +Implementers SHOULD review whether any fields they add to `ext` or `context` re-introduce data that the protocol minimized out. + +### Unsalted hashed identifiers are pseudonymous, not anonymous + +`hashed_email` and `hashed_phone` are **pseudonymous PII**. The email and E.164 namespaces are small enough that precomputed dictionaries and commercial reverse-lookup services recover plaintext from an unsalted SHA-256 hash; a hashed identifier is not equivalent to an anonymized identifier. + +Normative consequences: + +- Operator documentation, data-processing agreements, and compliance disclosures MUST NOT describe unsalted `hashed_email` or `hashed_phone` as "privacy-preserving", "anonymous", or "de-identified". Hashing is data minimization at the transport boundary, not anonymization. +- `hashed_email` and `hashed_phone` MUST be treated as PII for retention, consent, access control, data-subject-access (GDPR Art. 15), and erasure (GDPR Art. 17) workflows. A subject-access request for an email address MUST resolve to records keyed by its hash where the operator retains the corresponding hashed record. (An operator that genuinely cannot re-identify — e.g., that stores only aggregated match counts or bloom-filter membership bits — may invoke GDPR Art. 11; that bar is higher than "we hashed it.") +- Matching the above hashes against a seller's identity graph is a processing activity that needs its own lawful basis — hashing does not remove that requirement. + +A claim that a matching protocol is "privacy-preserving" requires a recognized primitive — salted hashing with operator-held secrets, HMAC with a cross-party shared secret, PSI (Private Set Intersection), or a TEE with attested separation. AdCP 3.0 does not define a salted or HMAC variant of `hashed_email`/`hashed_phone`; a standardized salted variant is tracked for a future minor release. Until then, implementers that need a privacy-preserving match MUST layer one of the above primitives above the protocol (e.g., clean-room processing, PAIR, or identity-graph tokenization as performed by UID2/RampID operators). + +### Separation + +Where two facts combined would create a privacy harm (e.g., identity + context at impression time), the protocol separates them. Structural separation is TMP-specific; other domains rely on contractual separation. + +### Transport + +All AdCP traffic is over HTTPS (see [Security — Identity](/dist/docs/3.0.13/building/concepts/security-model#layer-1-identity--who-is-actually-calling)). Signed requests (RFC 9421) are normative in 3.1. Transport security is a baseline assumption; the protocol builds on top of it. + +### Residency + +Residency is not carried in the protocol. An agent's residency posture is a configuration and a contract property — implementers MUST document it, and operators MUST configure it to meet EU / UK / other regional requirements where applicable. See the [Security Model — data handling and subprocessors checklist](/dist/docs/3.0.13/building/concepts/security-model#data-handling-and-subprocessors). + +### Retention + +The protocol describes cache retention for idempotency (see [Layer 3: Idempotency](/dist/docs/3.0.13/building/concepts/security-model#layer-3-idempotency--at-most-once-execution)) and audit-log retention for governance (see [Layer 5: Auditability](/dist/docs/3.0.13/building/concepts/security-model#layer-5-auditability--the-trail-survives-the-transaction)). Retention of other data — creative assets, campaign state, LLM prompts, conversation logs — is the operator's responsibility. + +### Processor / controller roles + +Who is a controller and who is a processor depends on the deployment. AdCP does not assign roles at the protocol layer. The typical allocation: + +- The buyer is typically a controller for the campaign they run. +- A governance agent acts as a processor for the controller(s) it serves and often has **multi-customer blast radius** — treat it accordingly in due diligence. +- A seller may be a controller for its own inventory data and a processor for buyer-scoped campaign data. +- A TMP Router operator is typically a processor for both sides, operating under the separation guarantees described in [TMP privacy architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture). + +For TMP-specific deployments, see [TMP Data Protection Roles](/dist/docs/3.0.13/trusted-match/data-protection-roles) — a deeper analysis covering the buyer agent's conditional processor position, the SSP's role when the context+identity join is delegated, identity provider risk shapes, and post-impression flows that fall outside TMP's separation guarantees. + +Operators MUST document their role for each data flow and carry a DPA with each counterparty that reflects it. + +### Subprocessors and LLM providers + +Every LLM-powered agent has subprocessors: the LLM provider itself, plus any retrieval services, embeddings stores, or tool integrations. The DPA with each provider must be explicit about whether prompts, brand assets, first-party signals, or creative metadata may be retained or used for model training. See the [Security Model — data handling checklist](/dist/docs/3.0.13/building/concepts/security-model#data-handling-and-subprocessors). + +LLM subprocessors also introduce an **integrity** risk, not only a confidentiality one: untrusted text in briefs, creative metadata, or tool outputs can carry prompt-injection payloads that cause the agent to leak credentials, issue unauthorized tool calls, or tamper with outputs. See [Threats specific to agentic advertising](/dist/docs/3.0.13/building/concepts/security-model#threats-specific-to-agentic-advertising). This is out of scope for the protocol but in scope for every operator. + +## Boundaries implementers must handle + +The protocol does not enforce these; the operator must: + +- **End-user consent and data-subject rights** (GDPR Art. 15–22, CCPA). +- **Purpose limitation** beyond what field shapes enforce. +- **Cross-border data transfer controls** (SCCs, adequacy, UK IDTA). +- **PII discovery in unstructured fields** (creative metadata, chat logs, brief text). AdCP does not scan `ext`, `context`, brief prose, or creative assets for PII. +- **Log retention and PII redaction** in logs. +- **Prompt-injection containment** for LLM-powered agents processing untrusted text. + +## For DPOs and procurement reviewers + +AdCP does not publish a Privacy Impact Assessment template. A PIA is a deployer artifact owned by the data controller and their counsel — every deployment's purpose, lawful basis, retention, residency, and subprocessor chain differs, and those are the parts that matter for a GDPR Art. 35 assessment. The protocol itself is not the controller and cannot stand in for that analysis. + +What AdCP provides instead is the set of protocol-level inputs a DPO needs to describe the AdCP portion of their processing. When assembling a PIA for a deployment that uses AdCP, the relevant inputs are: + +- **What each protocol carries and prohibits** — the [Privacy posture by domain](#privacy-posture-by-domain) summary above, plus the deep references linked from each domain. +- **Controller / processor allocation** — the [Processor / controller roles](#processor--controller-roles) section above. Operators still MUST document their role per data flow and carry a DPA with each counterparty. +- **Structural separation (TMP only)** — [TMP Privacy Architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture), including TEE attestation when deployed. +- **Threat model and operational controls** — the [Security Model](/dist/docs/3.0.13/building/concepts/security-model), including the [data handling and subprocessors checklist](/dist/docs/3.0.13/building/concepts/security-model#data-handling-and-subprocessors). +- **LLM-provider subprocessor considerations** — the [Subprocessors and LLM providers](#subprocessors-and-llm-providers) section above, which covers both confidentiality (retention, training) and integrity (prompt injection). +- **Explicit non-goals** — [Known Limitations](/dist/docs/3.0.13/reference/known-limitations), which names what the protocol does not do (no protocol-level PII transport, no residency mechanism, no breach-notification SLA, etc.) so the deployer knows which controls they own. + +Residency, retention, consent capture, data-subject rights workflows, cross-border transfer mechanisms, and purpose limitation are deployment concerns — AdCP does not enforce them at the protocol layer, and a template could not meaningfully cover them without becoming deployment-specific. + +## Related references + +- **[TMP Privacy Architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture)** — the structural separation model, with TEE attestation details +- **[Security Model](/dist/docs/3.0.13/building/concepts/security-model)** — threat model, layered defense, deployment checklist +- **[Security (implementation reference)](/dist/docs/3.0.13/building/by-layer/L1/security)** — normative rules for auth, idempotency, SSRF, governance verification +- **[Privacy posture across domains](/dist/docs/3.0.13/protocol/architecture#privacy-posture-across-domains)** — the summary table +- **[Known Limitations](/dist/docs/3.0.13/reference/known-limitations)** — what the protocol does not do on privacy diff --git a/dist/docs/3.0.13/reference/release-notes.mdx b/dist/docs/3.0.13/reference/release-notes.mdx new file mode 100644 index 0000000000..cd0dc40625 --- /dev/null +++ b/dist/docs/3.0.13/reference/release-notes.mdx @@ -0,0 +1,1313 @@ +--- +title: Release Notes +description: "AdCP release notes with version highlights and migration guides. Covers breaking changes, new tasks, and schema updates for each major release." +"og:title": "AdCP — Release Notes" +--- + + +High-level summaries of major AdCP releases with migration guidance. For detailed technical changelogs, see [CHANGELOG.md](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md). For version stability, schema-change scope, and the 3.x guarantees see [Versioning & Governance](/dist/docs/3.0.13/reference/versioning). For v2 end-of-life see the [v2 sunset page](/dist/docs/3.0.13/reference/v2-sunset). + +--- + +## Version 3.0.6 + +**Status:** Patch release — stable-surface no-op for 3.0-conformant agents + +**3.0.6 makes the `GOVERNANCE_DENIED` wire-placement rule discoverable from the error code itself**, reserves the `ctx_metadata` keyword as an adapter-internal round-trip key, expands the SKILL.md guidance for `issues[]` recovery on the calling-agent side, and fixes two storyboard fixture bugs that were rejecting spec-compliant adopters. Wire format unchanged for any 3.0 agent. + + +**Upgrading from 3.0.5?** No code changes required for 3.0-conformant agents. SDK consumers bump `ADCP_VERSION` to `3.0.6` to pick up the tightened error-code prose, the `ctx_metadata` reservation, and the corrected storyboard fixtures. + + +### Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing. Stable schemas remain wire-compatible with 3.0.0. | +| Returning `GOVERNANCE_DENIED` from `acquire_rights` or `creative_approval` | Read the new wire-placement guidance on the error code. The canonical denial shape is the structured rejection arm (`AcquireRightsRejected` / `CreativeRejected`) — `status: "rejected"` + `reason`, **no** `errors[]`, transport markers stay green. The schema's `not: { required: ["errors"] }` clause was already enforcing this; the prose now makes the rule discoverable from the code. | +| Returning `GOVERNANCE_DENIED` from `create_media_buy` (or any task without a rejection arm) | Continue populating `errors[].code` AND `adcp_error.code` per the two-layer model and flipping transport-level failure markers (HTTP 4xx / MCP `isError: true` / A2A `failed`). The wire-placement guidance distinguishes this Case-2 path from the rejection-arm path. | +| Building an SDK adapter that wants to round-trip publisher state through AdCP resources | You MAY now use the reserved top-level `ctx_metadata` key on Product / MediaBuy / Package / Creative / AudienceSegment / Signal / RightsGrant. SDKs MUST strip the key before wire egress and SHOULD log a warning when stripping. Buyers never see this field. | +| Authoring storyboards that capture state from A2A submitted-arm responses | The `task_completion.` prefix on `context_outputs[].path` is now documented in the storyboard schema. The runner polls `tasks/get` until terminal and resolves the suffix against the completion artifact's `data` — needed for captures like seller-assigned `media_buy_id` on IO-signing flows. Requires runner ≥ adcp-client v6.7. | +| Running `comply_test_controller` | The visibility rule is now explicitly deployment-scoped, not request-gated. Production deployments MUST NOT expose the tool on any surface (`tools/list`, `compliance_testing` block in `get_adcp_capabilities`, dispatch). Live-mode probes get unknown-tool, not `FORBIDDEN`. | + +### `GOVERNANCE_DENIED` / `GOVERNANCE_UNAVAILABLE` wire-placement guidance (#3929, closes the doc-comment item on #3918; companion to #3914) + +`error-code.json` defined the two governance codes' semantics but didn't say WHERE in the response they appear. Storyboards interpreted differently — issue #3914 surfaced one mismatch where the brand-rights compliance storyboard expected `expect_error: code: GOVERNANCE_DENIED` even though `acquire_rights` already has a first-class `AcquireRightsRejected` discriminated arm. Adopters returning the spec-correct shape were failing the storyboard. + +The `enumDescriptions` for both codes now state placement explicitly: + +- **`GOVERNANCE_DENIED`** — structured business outcome, not a system error. **When the task response defines a structured rejection arm**, that arm IS the canonical denial shape: populate `status: "rejected"` + `reason`, do NOT additionally emit the code in `errors[]` or `adcp_error`, and do NOT flip transport-level failure markers. **When the task has no rejection arm**, populate `errors[].code` AND `adcp_error.code` per the two-layer model and DO flip transport markers. +- **`GOVERNANCE_UNAVAILABLE`** — system error, governance call failed at all. Always populate both layers with the code and flip transport markers. Sellers MUST NOT use a structured rejection arm for unavailability even when the task offers one — the buyer's recovery semantics differ (retry-with-backoff vs. restructure-or-escalate). + +The MUST NOT against dual-emission isn't a behavior change — `AcquireRightsRejected` and `CreativeRejected` already declare `not: { required: [errors] }` at the schema layer, so emitting `errors[]` alongside a rejection arm was already a schema violation. The doc-comment makes the rule discoverable from the error code without changing what conformant senders produce. + +A parallel storyboard-authoring note in `error-handling.mdx` directs authors to assert on `field_value path: "status" value: "rejected"` rather than `error_code` for tasks that define a rejection arm. The existing `error_code` guidance is correct for tasks without a rejection arm. + +### `ctx_metadata` reserved as adapter-internal round-trip key (#3640) + +Reserves the top-level key `ctx_metadata` on AdCP resource objects (Product, MediaBuy, Package, Creative, AudienceSegment, Signal, RightsGrant) as a publisher-to-SDK round-trip cache for adapter-internal state. SDKs MUST strip the key before wire egress and MUST emit a warning-level log entry when stripping, so operators can detect accidental collisions with existing adapter code. Buyers never see this field. + +The convention is non-binding at the wire level — these resources already declare `additionalProperties: true` so existing payloads remain valid. The reservation locks the keyword name before two SDKs converge on it accidentally and ship divergent semantics. PropertyList and CollectionList are out of scope (`additionalProperties: false`) until a follow-up PR widens those schemas. + +### Implementation-dependent `issues[]` fields documented in SKILL.md (#3927 backport) + +`skills/call-adcp-agent/SKILL.md` already documented the three required `issues[]` fields (`pointer`, `keyword`, `variants`). 3.0.6 adds the four optional fields a calling agent will encounter when the seller's validator opts into them — `discriminator`, `schemaId`, `allowedValues`, `hint` — with a one-line preface clarifying these are implementation-dependent (not every validator emits them) and an updated recovery order: read `hint` first when present, then `discriminator`, then walk `variants`. Two new rows added to the symptom-fix lookup table for the same fields. + +No wire-format change. Pure documentation: shipping these fields is already a valid validator extension; this gives callers a curated path through them. + +### Storyboard-schema documents `task_completion.` prefix (#3955, closes #3950) + +The `context_outputs[].path` resolver gained a `task_completion.` prefix in the storyboard runner (`@adcp/sdk` 6.7+) for capturing values that materialize only on the terminal task artifact (e.g., seller-assigned `media_buy_id` on IO-signing flows where `create_media_buy` returns an A2A submitted-arm envelope). 3.0.6 adds the corresponding documentation to the storyboard authoring schema (`static/compliance/source/universal/storyboard-schema.yaml`). + +### `comply_test_controller` is deployment-scoped, not request-gated (#3992) + +Tightens the visibility rule for `comply_test_controller`: production deployments MUST NOT expose the tool on any surface — neither `tools/list`, nor the `compliance_testing` block in `get_adcp_capabilities`, nor request dispatch. Live-mode probes get unknown-tool (treated as a regular catalog miss), **not** `FORBIDDEN`. The previous prose left enough room that some adopters were emitting `FORBIDDEN` on live-mode dispatch, which is itself an information leak (an attacker probing for the tool can distinguish "not deployed" from "deployed but you can't use it"). + +### Storyboard fixture fixes + +Two compliance-bundle fixture fixes that were causing spec-compliant adopters to fail published storyboards: + +- **`inventory_list_targeting`** — the 5 account blocks across this scenario use the brand+operator natural-key variant of `AccountReference` but omitted the `sandbox` flag. Sellers whose `accounts.resolve` has separate code paths for sandbox vs production refs were routing `create_media_buy` and `get_media_buys` through different account-id namespaces, breaking `mediaBuyStore` backfill of `targeting_overlay`. Setting `sandbox: true` on every account block keeps both create and get on the sandbox path. Mirror of [#3989](https://github.com/adcontextprotocol/adcp/pull/3989) on `main`. Follow-up to align the SDK runner's enricher asymmetry tracked at [adcp-client#1487](https://github.com/adcontextprotocol/adcp-client/issues/1487). +- **`sales_guaranteed/create_media_buy`** — the `context_outputs[0].path` was bare `"media_buy_id"`, which the runner resolved against the immediate submitted-arm response — a step that fails with `capture_path_not_resolvable` and masks downstream phases. Updated to `"task_completion.media_buy_id"` so the runner polls `tasks/get` and captures the seller-issued id from the terminal artifact, per the runner contract introduced in adcp-client#1426. Mirror of [#3990](https://github.com/adcontextprotocol/adcp/pull/3990) on `main`. + +--- + +## Version 3.0.5 + +**Status:** Patch release — stable-surface no-op for 3.0-conformant agents + +**3.0.5 unblocks `brand_json_url` adoption on 3.0**, ships an optional storyboard-authoring affordance, and corrects a brand-rights storyboard capture path that was rejecting spec-compliant agents. Wire format unchanged for any 3.0 agent that doesn't claim a new optional surface. + + +**Upgrading from 3.0.4?** No code changes required for 3.0-conformant agents. SDK consumers bump `ADCP_VERSION` to `3.0.5` to pick up the relaxed `identity` validator and the brand-rights storyboard fix. + + +### Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing. Stable schemas remain wire-compatible with 3.0.0. | +| Adopting `identity.brand_json_url` from #3690 on 3.0 | Bump to 3.0.5 (or have your SDK pick it up). 3.0.4 and earlier rejected the field at validation; 3.0.5 accepts it. | +| Running brand-rights conformance against the published storyboard | Bump SDK to pick up `dist/compliance/3.0.5/specialisms/brand-rights/index.yaml`. Spec-compliant agents that return `rights_id` (per the published `acquire-rights-response.json`) now pass `rights_acquisition` and stop cascade-skipping `rights_enforcement`. | +| Authoring multi-agent storyboards | You MAY now declare a top-level `default_agent: ` so multi-agent runners route cross-domain steps without per-CI-invocation overrides. Single-agent runs ignore the field. | + +### `identity.additionalProperties: true` on `get_adcp_capabilities` (#3896, closes Scope3 adoption gap) + +The `identity` block on `get-adcp-capabilities-response.json` was schema-closed (`additionalProperties: false`), which was the lone outlier among capability blocks — every peer (`media_buy`, `signals`, `creative`, `brand`, `compliance_testing`, `request_signing`, `webhook_signing`, `measurement`) already had `additionalProperties: true` at the outer level. The closed shape silently contradicted the forward-compat promise made by [#3690](https://github.com/adcontextprotocol/adcp/pull/3690) (`brand_url on get_adcp_capabilities for keys-from-agent-URL discovery`), which explicitly stated that 3.0-pinned implementers could adopt `identity.brand_json_url` without waiting for a schema bump. + +Without this relaxation, `@adcp/sdk`'s `createAdcpServer` (default strict-validation mode) rejected any operator response carrying `brand_json_url`, forcing adopters to disable validation entirely (a footgun) or wait for 3.1. + +3.0.5 mirrors what `main` already shipped post-#3690: the outer `identity` object opens; the inner blocks (`key_origins`, `compromise_notification`) stay closed where the security weight actually sits. Strictly additive — the closed property list (`per_principal_key_isolation`, `key_origins`, `compromise_notification`) is unchanged; receivers that ignore unknown fields keep working; receivers that look for new identity fields gain forward-compat without waiting for a 3.x bump. Buyers and verifiers SHOULD continue to allowlist known identity fields at read time rather than rely on schema closure for trust decisions. + +### Storyboard-level `default_agent` field (#3897, closes #3894) + +Optional top-level `default_agent: ` on the storyboard authoring schema (`dist/compliance/3.0.5/universal/storyboard-schema.yaml`). The multi-agent storyboard runner ([adcp-client#1066](https://github.com/adcontextprotocol/adcp-client/issues/1066), [#1355](https://github.com/adcontextprotocol/adcp-client/pull/1355)) already accepts `default_agent` via run-options; this change lets storyboard authors encode the topology intent in YAML once instead of re-asserting `--default-agent sales` on every CI invocation. Cross-domain tools (`sync_creatives`, `list_creative_formats`) become deterministic without per-step `agent:` overrides. + +Resolution order (runner contract): + +1. Step-level `agent:` override. +2. Specialism-claimant match against the runtime agents map. Multi-claim grades `unrouted_step` (operator-config error); slots 3/4 do not rescue. Zero claimants falls through to slot 3. +3. Storyboard-level `default_agent` (this field). Set-but-unmatched grades `default_agent_unresolved` — the runner does NOT silently fall through to slot 4, because that would invisibly override the storyboard author's encoded intent. +4. Run-options `default_agent`. Same set-but-unmatched rule. +5. Fail-fast — `unrouted_step`. + +Single-agent runs ignore the field entirely; existing 3.0.x storyboards keep working unchanged. Mirrors the `provides_state_for` precedent (#3775) for additive storyboard-schema affordances on 3.0.x. + +The key shape is a free-form non-empty string keyed by the runtime agents map — the spec does not constrain to the specialism enum because production multi-agent topologies legitimately fan out per-property (`nyt_sales`, `wsj_sales`), per-region (`sales_eu`, `sales_us`), or per-brand-rights-holder. Cross-operator portability is the storyboard author's concern, not the spec's. + +### Brand-rights storyboard `acquire_rights` capture fix (#3893, closes #3892) + +The `brand_rights/rights_acquisition` storyboard's `acquire_rights` step captured a `context_outputs` field at path `rights_grant_id`, but `brand/acquire-rights-response.json` defines that field as `rights_id` (the `AcquireRightsAcquired` arm). Spec-compliant agents passed `response_schema` validation but failed the capture-and-pass-to-next-step machinery, which then cascade-skipped `rights_enforcement` with `prerequisite_failed`. + +3.0.5 corrects the storyboard to read `rights_id` (preserving the storyboard-internal `rights_grant_id` key name so no other steps need updates) and aligns the `expected:` prose to match the published schema (`status: acquired`, not the legacy `status: active`). + +Adopters running brand-rights conformance against a spec-compliant agent: bumping your SDK past 3.0.4 should flip the `brand_rights` storyboard from 3/5 scenarios passing to 5/5 with no agent-side changes. + +### Release mechanics (#3820) + +`forward-merge-3.0.yml`: explicitly push the `forward-merge/3.0.x` branch to origin **before** `peter-evans/create-pull-request@v8` runs. Discovered when 3.0.4's forward-merge ran for real: auto-resolution succeeded, then peter-evans crashed with `fatal: ambiguous argument 'origin/forward-merge/3.0.x': unknown revision`. Last gap in the auto-resolution chain — every subsequent Version Packages cut now auto-creates the forward-merge PR without human intervention. + +### Detailed changelog + +For the full per-PR change list, see [CHANGELOG.md § 3.0.5](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#305). + +--- + +## Version 3.0.4 + +**Status:** Patch release — stable-surface no-op for 3.0-conformant agents + +**3.0.4 is the third 3.0.x patch.** Three additive cherry-picks from main, all hand-adapted for the maintenance line: the `manifest.json` + structured `enumMetadata` artifact (so SDKs stop hand-transcribing the spec), a normative `issues[]` array on `core/error.json`, and prose-only tightening of `AUTH_REQUIRED` to call out the retry-storm risk. Wire format unchanged. + + +**Upgrading from 3.0.3?** No code changes required for 3.0-conformant agents. SDK consumers bump `ADCP_VERSION` to `3.0.4` to pick up `manifest.json` and the new `enumMetadata` block. + + +### Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing. Stable schemas remain wire-compatible with 3.0.0. | +| An SDK author | Switch from parsing `Recovery: X` prose out of `enumDescriptions` to consuming the structured `enumMetadata` block. The build-time lint guarantees structured/prose parity, so the prose path can stay as a fallback while you migrate. | +| An SDK consumer | Bump `ADCP_VERSION` to `3.0.4`. Pick up `/schemas/3.0.4/manifest.json` for one-stop tool/error/specialism enumeration. | +| Implementing a buyer agent | Read the new `AUTH_REQUIRED` sub-cases in [error-handling.mdx](/dist/docs/3.0.13/building/by-layer/L3/error-handling) — the wire code stays the same but you SHOULD NOT auto-retry when credentials were attached and rejected (terminal case). 3.1 will split this into separate enum values via #3739. | +| Returning multi-field validation errors | Optionally populate `core/error.json`'s new top-level `issues[]` array (each entry: RFC 6901 pointer, message, JSON Schema keyword). Pre-3.1 consumers reading only `field` get the first failure; 3.1+ consumers prefer `issues`. | + +### `manifest.json` + structured `enumMetadata` (#3725, #3738) + +Two additive artifacts published with every released schema bundle: + +1. **`enums/error-code.json` gains an `enumMetadata` block.** Every error code now carries structured `recovery` (`correctable` | `transient` | `terminal`) and `suggestion` fields. SDKs MUST consume this block instead of parsing `Recovery: X` prose out of `enumDescriptions` — a build-time lint enforces structured/prose parity. Closes the root cause of [adcp-client#1135](https://github.com/adcontextprotocol/adcp-client/issues/1135) (17 missing codes, 3 wrong recovery classifications shipped in TS SDK for over a year). + +2. **`/schemas/3.0.4/manifest.json`.** Single canonical artifact listing every tool (with `protocol`, `mutating`, `request_schema`, `response_schema`, `async_response_schemas`, `specialisms`), every error code (with `recovery`, `description`, `suggestion`), an `error_code_policy` block (defining `default_unknown_recovery` so SDKs handle non-spec codes correctly), and every storyboard specialism (with `protocol`, `entry_point_tools`, `exercised_tools`). Validates against `manifest.schema.json`. Lets SDKs derive their internal tool/error tables from one place at codegen time. + +The 3.0.4 manifest covers exactly the 45 error codes 3.0.x ships (vs. main's 48 — three of main's codes don't exist in 3.0.x's enum and were trimmed during the cherry-pick). + +### `core/error.json` — `issues[]` field (#3059, #3562) + +Optional top-level `issues` array on the standard error envelope, normalizing what `@adcp/sdk` and prospectively `adcp-go` / `adcp-client-python` already need for multi-field validation rejections. + +```json +{ + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "field": "creatives[0].assets.image", + "issues": [ + { + "pointer": "/creatives/0/assets/image", + "message": "Required", + "keyword": "required" + }, + { + "pointer": "/creatives/0/format_id", + "message": "Must match pattern", + "keyword": "pattern" + } + ] +} +``` + +Each entry is `{ pointer (RFC 6901), message, keyword, schemaPath? }`. `schemaPath` MAY be omitted in production to avoid fingerprinting `oneOf` branch selection on adversarial payloads. + +**Backward compatibility with `field` (singular):** when both are present, sellers SHOULD set `field` to `issues[0].pointer`. Pre-3.1 consumers reading only `field` get the first failure; 3.1+ consumers prefer the top-level `issues`. Sellers MAY mirror `issues[]` into `details.issues` for backward compat with consumers reading from `details`. + +### `AUTH_REQUIRED` retry-storm prose (#3730 partial, #3739 backport) + +`AUTH_REQUIRED` conflates two operationally distinct cases — credentials missing (genuinely correctable) and credentials presented but rejected (terminal — needs human rotation). A buyer agent treating both as `correctable` will retry-loop on revoked tokens, hammering seller SSO endpoints in a pattern indistinguishable from a brute-force probe. + +The 3.1 line splits this into `AUTH_MISSING` and `AUTH_INVALID` (#3739). 3.0.x cannot adopt the split — adding new enum values violates the maintenance line's semver rules. 3.0.4 ships the prose-only backport: the wire code stays `AUTH_REQUIRED` with `recovery: correctable`, but the description and `enumMetadata.suggestion` now spell out the two sub-cases and the SHOULD-NOT-auto-retry rule for the rejected-credential case. SDKs running against 3.0.x sellers can apply the operational distinction at the application layer. + +`docs/building/implementation/error-handling.mdx` gets a sub-case callout and an updated example showing how to branch on whether credentials were attached. Closes the 3.0.x portion of #3730; the full split lands in 3.1.0. + +### Detailed changelog + +For the full per-PR change list, see [CHANGELOG.md § 3.0.4](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#304). + +--- + +## Version 3.0.3 + +**Status:** Patch release — additive storyboard schema field, stable-surface no-op for 3.0-conformant agents + +**3.0.3 ships the `provides_state_for` storyboard field** so the conformance suite can rescue cascade-skipping when two interchangeable stateful steps live in the same phase. Plus a docs-only fix for the `url_type` enum in channel docs that was emitting a value the published schema already excluded. + + +**Upgrading from 3.0.2?** No code changes required for 3.0-conformant agents. Storyboard runners on `@adcp/sdk` 6.5.0+ pick up the new field automatically once the cache refreshes against 3.0.3. + + +### Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing. Stable schemas unchanged. | +| Authoring storyboards | Optionally use `provides_state_for: ` on a stateful step to declare it substitutes for a missing peer step's state. Same-phase only; both steps must be `stateful: true`. The build-time lint enforces shape, target validity, statefulness, no self-reference, and no two-step cycles. | +| Running storyboards via `@adcp/sdk` | Bump to 6.5.0+ to pick up the cascade-rescue runtime. Older SDK versions ignore the field and fall back to the existing `missing_tool` cascade behavior. | +| A storyboard-authoring docs source (channels) | Replace `"url_type": "tracker"` with `"url_type": "tracker_pixel"` in any examples. The published schema enum already excluded `"tracker"`, so existing valid wire payloads are unaffected — only the prose docs were drifting. | + +### `provides_state_for` storyboard field (#3734) + +Optional `provides_state_for: | []` on a stateful storyboard step declares that this step's pass establishes equivalent state for the named peer step(s) in the same phase. Pairs with the cascade-skip mechanism in `@adcp/sdk` 6.5.0+: when a peer step would otherwise grade `missing_tool` or `missing_test_controller`, the substitute waives the cascade and the runner grades the peer with the new `peer_substituted` skip reason. + +**Concrete impact:** explicit-mode social platforms (Snap, Meta, TikTok) intentionally pre-provision advertiser accounts out-of-band — `sync_accounts` is `missing_tool` by design, with `list_accounts` as the canonical alternative. 3.0.3's `sales-social/index.yaml` declares `provides_state_for: sync_accounts` on the `list_accounts` step, letting these adapters graduate from `1/9/0` (8 downstream stateful steps cascade-skipped) to `9/10` against the `sales_social` storyboard once the SDK cache refreshes. + +The field is part of the conformance harness, so it ships under the harness-additive patch-eligibility rule. Existing storyboards that don't use it keep their current cascade behavior — pure additive. + +Build-time validation (`scripts/lint-storyboard-provides-state-for.cjs`): rule shape, self-reference, unknown target, cross-phase reference (rejected — must be same-phase), target-not-stateful, substitute-not-stateful, and direct-cycle violations all fail loud. + +### `runner-output-contract.yaml` — `peer_substituted` skip reason + +Companion to `provides_state_for`: when the runner waives a cascade because a same-phase peer substituted for the state contract, it grades the original peer with `skip_result.reason = peer_substituted` and detail `" state provided by ."`. Distinct from `peer_branch_taken` (branch-set routing for mutually exclusive behaviors) and `not_applicable` (coverage gap — agent didn't declare the protocol). + +### `url_type: tracker` → `tracker_pixel` (#2986 step 1) + +Display, audio, carousels, and DOOH channel docs were emitting `"url_type": "tracker"` in examples — a value the published `url-asset-type.json` enum (`clickthrough` / `tracker_pixel` / `tracker_script`) already excluded. Fixed to `tracker_pixel`. Wire format unchanged; only prose docs were drifting. + +### Detailed changelog + +For the full per-PR change list, see [CHANGELOG.md § 3.0.3](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#303). + +--- + +## Version 3.0.2 + +**Status:** Patch release — additive storyboard check kind + canonical asset-union schema + +**3.0.2 ships a new storyboard check kind** that closes a static-analysis gap in `@adcp/sdk`'s drift verifier, plus extracts a shared asset-variant `oneOf` union into its own schema file so codegen tools (notably `json-schema-to-typescript`) stop emitting numbered duplicate types. + + +**Upgrading from 3.0.1?** No code changes required for 3.0-conformant agents. The check kind is consumed by the conformance runner, not by sellers; the asset-union refactor is a wire-format no-op. + + +### Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing. Wire format and validation semantics unchanged. | +| An SDK author running codegen against schemas | Re-run `json-schema-to-typescript` (or your equivalent) to drop the `VASTAsset1`, `DAASTAsset1`, `BriefAsset1`, `CatalogAsset1` numbered duplicates. They were artifacts of the same `oneOf` union being encountered through multiple parent schemas; 3.0.2 references the canonical `core/assets/asset-union.json` from both `creative-asset.json` and `creative-manifest.json`. | +| Authoring storyboards that assert envelope-level fields | Optionally use the new `envelope_field_present` check kind in place of `field_present` for `protocol-envelope.json` fields like `status`. The new check walks the envelope schema rather than the step's `response_schema_ref`, eliminating the static-analysis `VERIFIER_UNREACHABLE` gap in adcp-client's storyboard-drift verifier. | +| Running storyboards via `@adcp/sdk` | Bump to the version that lands [adcp-client#1045](https://github.com/adcontextprotocol/adcp-client/pull/1045) for the new check kind. | + +### `envelope_field_present` check kind + +Storyboard `validations[].check` gains `envelope_field_present` as a peer of `field_present`. Same shape — `path` is RFC 6901-style — but resolves the path against `protocol-envelope.json` rather than the step's `response_schema_ref`. Used in `static/compliance/source/universal/v3-envelope-integrity.yaml` to assert that responses include `status`, where the previous `field_present` check left a `VERIFIER_UNREACHABLE` hole because `status` lives on the envelope, not the per-task response schema. + +### Canonical `core/assets/asset-union.json` + +The asset-variant `oneOf` union (the discriminated set of `image | video | text | url | vast | daast | ...` shapes) was inlined identically in `creative-asset.json` and `creative-manifest.json`. `json-schema-to-typescript` walking those parent schemas independently emitted `VASTAsset1`, `DAASTAsset1`, `BriefAsset1`, `CatalogAsset1` numbered-duplicate types — invisible at the wire level, irritating in generated code. + +3.0.2 promotes the union to `core/assets/asset-union.json` and references it via `$ref` from both parents. Codegen now emits a single `Asset` (or whatever your tool chooses) without the numbered duplicates. Wire format and validation semantics unchanged — pure refactor of the schema reference graph. + +### Detailed changelog + +For the full per-PR change list, see [CHANGELOG.md § 3.0.2](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#302). + +--- + +## Version 3.0.1 + +**Status:** Patch release — stable-surface no-op for 3.0-conformant agents + +**3.0.1 is a maintenance release.** It ships the protocol skills bundle through the canonical tarball, formalises a handful of normative clauses left underspecified in 3.0.0, and adds small additive fields on experimental surfaces (governance, TMP) and the conformance harness. The stable wire surface is unchanged. + + +**Upgrading from 3.0.0?** No code changes required for 3.0-conformant agents. SDK consumers bump `ADCP_VERSION` from `3.0.0` to `3.0.1` to receive the canonical skills via their existing sync flow. + + +### Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing. Stable schemas are unchanged. | +| An SDK consumer | Bump `ADCP_VERSION` from `3.0.0` to `3.0.1`. JS wiring in [adcp-client#965](https://github.com/adcontextprotocol/adcp-client/pull/965); Python and Go follow-ups in [adcp-client-python#274](https://github.com/adcontextprotocol/adcp-client-python/issues/274) and [adcp-go#91](https://github.com/adcontextprotocol/adcp-go/issues/91). | +| Implementing governance or TMP | Review the experimental-surface additions below. Per the [experimental-status contract](/dist/docs/3.0.13/reference/experimental-status), additive changes are permitted within a patch release. | +| Calling `get_signals` with top-level `max_results` | Migrate to `pagination.max_results`. Top-level field still works through 3.x; removed in 4.0. | + +### Why 3.0.1 exists + +The `skills/` directory was hoisted into the protocol root after the 3.0.0 tarball was already cosign-signed. Re-cutting at the same version would have invalidated the supply-chain attestations bound to the original 3.0.0 SHA-256. 3.0.1 ships the bundle through the normal release path. (#3116, #3117) + +### Spec polish — no wire change + +Normative clarifications and docs corrections. None of these add new fields to stable schemas; they document behaviour the spec already implied. + +- **`acquire_rights` request validation** — Brand agents MUST reject expired campaign windows (`campaign.end_date` in the past) with `INVALID_REQUEST`. CPM-priced rights under a governed plan must include `campaign.estimated_impressions`. Closes implementer disagreement on identical requests. (#2680, #2681) +- **`get_signals` pagination precedence** — When both top-level `max_results` and `pagination.max_results` are present, agents MUST honor `pagination.max_results`. Top-level `max_results` deprecated; removed in 4.0. +- **URL canonicalization** — Now applies uniformly to brand.json agent URLs (`brand_agent_entry.url`, `brand_agent.url`, `rights_agent.url`). Two URLs differing only in case, default port, or percent-encoded unreserved characters compare equal during agent resolution. New reference page at [URL canonicalization](/dist/docs/3.0.13/reference/url-canonicalization). +- **v3 envelope integrity** — Schema-level constraint on `protocol-envelope.json` formally prohibits legacy v2 `task_status` / `response_status` field names. The prose MUST NOT was already in 3.0.0; the constraint is now machine-detectable by validators. Companion universal storyboard `v3-envelope-integrity` exercises the assertion. Conformant 3.0 implementations are unaffected. (#3041) +- **Format asset codegen** — Title annotations on `format.json` `oneOf` branches enable codegen tools (json-schema-to-typescript, datamodel-code-generator, oapi-codegen) to emit named per-asset-type interfaces instead of untyped unions. Annotation-only. +- **Inline-enum hoisting** — Source schemas now `$ref` shared enum files for `payment-terms`, `audio-channel-layout`, `media-buy-valid-action`, `match-type`, `governance-decision`, `billing-party`, and 11 others. Bundled wire format unchanged in all cases. + +### Experimental surfaces — additive only + +Per the [experimental-surface contract](/dist/docs/3.0.13/reference/experimental-status), these surfaces accept additive changes within a patch release. Changes here do not affect agents that don't implement these protocols. + +- **Governance** — `mode` field added to `check-governance-response.json` and `get_plan_audit_logs` audit entries. Records the enforcement posture (enforce/advisory/audit) active at check time. Closes a gap where audit and enforce modes produced identical-looking trails. +- **Trusted Match Protocol (TMP)** — `seller_agent: { agent_url, id? }` added to `AvailablePackage`, making seller identity explicit on every package cached by a TMP provider. New `seller_not_authorized` error code for sync-time rejection when the seller's `agent_url` is not present in the property publisher's `adagents.json`. All TMP schemas now carry `x-status: experimental`. + +### Conformance harness — sandbox-only + +`comply_test_controller` is conformance-harness scaffolding, not a normative protocol task. Changes here only affect sandbox testing. + +- **`force_task_completion`** — Resolves a previously-submitted async task to `completed` with a buyer-supplied result payload. Closes the loop on the async `create_media_buy` submitted → completed roundtrip. Result is delivered via the seller's webhook (canonical 3.0 path); a typed result projection on the polling response is tracked for 3.1 in #3123. +- **`seed_creative_format`** — Pre-populates a deterministic set of creative formats for pagination-integrity storyboards. Companion: `list_creative_formats` now applies cursor-based pagination matching the `list_creatives` pattern. + +### Release mechanics + +- Cosign signing on private packages so future Version Packages merges auto-tag and `changesets/action` auto-creates the GitHub Release with artifacts. +- `dist/protocol/` retained in the Fly.io image so cosign-signed versioned tarballs ship and `/protocol/{version}.tgz` actually serves end-to-end. +- Skills bundled at `/protocol/3.0.1.tgz`: `call-adcp-agent` plus the per-protocol `adcp-{brand,creative,governance,media-buy,si,signals}`. + +### Detailed changelog + +For the full per-PR change list, see [CHANGELOG.md § 3.0.1](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#301). + +--- + +## Version 3.0.0 + +**Status:** General Availability | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) + +**AdCP 3.0 makes agent-to-agent ad buying retry-safe and auditable, with optional end-to-end request signing for AdCP Verified agents.** Four trust primitives carry mutating traffic — three are baseline-required in 3.0 (request-side idempotency, the RFC 9421 profile on webhooks, signed JWS governance) and RFC 9421 request signing is optional unless an agent claims AdCP Verified. The compliance runner proves an agent does each of them right. Storyboards move into the protocol at `/compliance/{version}/` as the bar; AdCP Verified is a self-attested stamp that an agent published passing runner output. 3.0 also brings broadcast TV as a first-class channel, generalizes governance to any purchase type, and simplifies the capabilities model so object presence replaces dozens of boolean flags. + + +**Upgrading from rc.3?** See [rc.3 → 3.0 prerelease upgrade notes](/dist/docs/3.0.13/reference/migration/prerelease-upgrades) for the breaking changes table, before/after examples, and migration steps. + + +### What's New + +1. **Trust surface: four cryptographic primitives for agent-to-agent ad buying** — Grouped by symmetry on the wire, so what's true of a request is true of a webhook: + + **Requests** — buyer → seller: + - **`idempotency_key` required on every mutating request** — fresh key per logical operation, matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Sellers declare dedup semantics via `adcp.idempotency = { supported: true, replay_ttl_seconds: ... }` (1h–7d, 24h recommended) or `{ supported: false }`. When `supported: true`: `replayed: true` on exact replay, `IDEMPOTENCY_CONFLICT` on payload mismatch, `IDEMPOTENCY_EXPIRED` past TTL. When `supported: false`: retries double-process — buyers MUST use natural-key checks instead. Extended to `activate_signal`. Conformance runners probe `supported: true` claims with a deliberate payload-mutation replay. (#2315, #2407, #2436, #2447) + - **RFC 9421 HTTP Message Signatures** — optional in 3.0, required for AdCP Verified. Ed25519 over a canonicalized covered-component list (including `content-digest`). sf-binary and URL canonicalization pinned so independent implementations produce bit-identical canonical inputs. Verifier follows a 15-step checklist (`keyid` cap-before-crypto, SSRF-validated JWKS fetch, `jti` replay dedup, audience binding) — see the [Security guide](/dist/docs/3.0.13/building/by-layer/L1/security). Published test vectors under `static/compliance/source/test-vectors/request-signing/`. (#2323, #2341, #2342, #2343) + + **Webhooks** — seller → buyer, same profile in reverse: + - **Webhook signing unified on the RFC 9421 profile — baseline-required for sellers emitting webhooks** — Sellers sign outbound webhooks with a key published in their JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). The JWK carries `adcp_use: "webhook-signing"` to distinguish it from the request-signing key; `kid` values MUST be unique across purposes within a JWKS. No shared secret crosses the wire. Verification failures return typed `webhook_signature_*` reason codes defined in the Security guide. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); removed in 4.0. (#2423) + - **Webhook payloads carry a required `idempotency_key`** — Every webhook is dedupable by a sender-generated UUID v4, using the same `^[A-Za-z0-9_.:-]{16,255}$` format as request-side keys. Replaces fragile `(task_id, status, timestamp)` dedup across five webhook payload schemas. `revocation-notification.notification_id` renamed to `idempotency_key` for protocol-wide consistency. (#2416, #2417) + + **Governance** — signed authority: + - **Signed JWS `governance_context`** — governance decisions are cryptographically verifiable offline. The governance agent issues a JWS signed with its key from `sync_governance`; sellers verify and bind decisions to `sub` (buyer), `aud` (seller), `phase`, and `exp` without round-tripping. Stale or forged decisions are rejected at the transport layer. Sellers with a configured governance agent MUST call `check_governance` before committing budget (rejection with `PERMISSION_DENIED` on missing or invalid context). (#2316, #2403, #2419) + + See [Security implementation guide](/dist/docs/3.0.13/building/by-layer/L1/security) for the threat model, `adcp_use` JWK taxonomy, and per-primitive verification paths. + +2. **Specialisms, compliance storyboards, and AdCP Verified** — Trust primitives define the bar; storyboards test that an agent actually meets it; AdCP Verified certifies the result. Storyboards move into the protocol at `/compliance/{version}/` (universal + protocols + specialisms + test-kits). Every agent runs `/compliance/{version}/universal/security.yaml` regardless of claims — unauth rejection, API key enforcement, OAuth discovery per RFC 9728, audience binding, and (when signing is claimed) the `signed_requests` and `signed_webhooks` runner harnesses. Runner output is a structured, verifiable `runner-output.json` with a hash chain over the test-kit corpus. Cross-instance state persistence is required. New `specialisms` field on `get_adcp_capabilities` lets agents claim narrow capability specialisms across 6 protocols (media-buy, creative, signals, governance, brand, sponsored_intelligence). `sponsored_intelligence` is promoted from specialism to full protocol. `broadcast-platform` → `sales-broadcast-tv`, `social-platform` → `sales-social`. `property-governance` + `collection-governance` split into sibling `property-lists` and `collection-lists` specialisms. Compliance taxonomy renames `domains` → `protocols`; `audience-sync` reclassified from `governance` to `media-buy`. Per-version protocol tarball at `/protocol/{version}.tgz`. The formal AdCP Verified program launches with **3.1** once reference implementations (training agent, SDKs) and ambiguous-storyboard work reach full compliance on a 4–6 week cadence; 3.0 Verified is self-attested via published runner output. See the [Compliance Catalog](/dist/docs/3.0.13/building/verification/compliance-catalog) and [What's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3#specialisms-and-storyboard-driven-compliance) for the rationale and timeline. (#2176, #2300, #2304, #2332, #2336, #2350, #2352, #2363, #2381) + +3. **Broadcast TV Support** — Linear TV sellers can now fully participate in AdCP. Ad-ID identifiers on creative assets and manifests. Broadcast spot formats for :15, :30, and :60 spots. `agency_estimate_number` on media buys and packages. Measurement windows (Live, C3, C7) on reporting capabilities. Delivery data completeness flags (`is_final`, `measurement_window`) for provisional vs. closed numbers. Broadcast forecast schema adds `measurement_source` and `guaranteed_impressions`. `station_id` and `facility_id` identifier types. (#2046, #1853, #1912) + +4. **Governance Generalization and Regulatory Invariants** — Governance extends beyond media buys to cover brand rights licensing, signal activation, and creative services. `governance_context` replaces `media_buy_id` as the identifier tying governance actions across a campaign's lifecycle. New `purchase_type` field distinguishes the governed activity. GDPR Art 22 / EU AI Act Annex III are enforced as schema invariants: `budget.authority_level` splits into `budget.reallocation_threshold` (autonomy) and `plan.human_review_required` (Art 22 review); cross-field `if/then` rejects `human_review_required: false` for regulated verticals (fair housing, fair lending, fair employment, pharmaceutical). Append-only `revisionHistory`; downgrades require a `human_override` artifact. `eu_ai_act_annex_iii` seeded in the registry. `data_subject_contestation` on `brand.json` satisfies Art 22(3) discovery. (#2014, #2310, #2338) + +5. **Capabilities Model Simplification** — Redundant boolean gates are removed throughout `get_adcp_capabilities`. If a `content_standards` object exists in the response, 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`, `geo_regions`, `geo_metros`, `geo_postal_areas`). (#2143, #2157) + +6. **Collection Lists** — Program-level brand safety for shows, series, and podcasts. Collection lists parallel property lists but target content programs using distribution identifiers (IMDb, Gracenote, EIDR) for cross-publisher matching. New `collection_list` and `collection_list_exclude` fields on the targeting overlay. Genre taxonomy enum normalizes classification. 16 new collection schemas. (#2005) + +7. **Structured Measurement Terms** — Guaranteed buys gain a formal negotiation surface. `measurement_terms` covers billing vendor, IVT threshold, and viewability floor. Sellers declare defaults; buyers propose overrides at `create_media_buy`; sellers accept, reject, or adjust. `cancellation_policy` schema declares notice periods and penalties. `TERMS_REJECTED` error code. (#1962) + +8. **Unified Vendor Pricing** — Pricing extends from signals to creative, governance, and property list agents. All vendor pricing now uses a shared schema covering CPM, percent-of-media, and flat-fee models. `pricing_options[]` on `list_creatives`, `build_creative`, `get_creative_features`, and property lists. (#1937) + +9. **Per-Request Version Declaration** — `adcp_major_version` on all 56+ request schemas lets buyers declare which major version their payloads conform to. Sellers return `VERSION_UNSUPPORTED` on mismatch. When omitted, sellers default to their highest supported version. (#1959) + +10. **Offline Reporting Delivery** — Sellers can push reports to buyer-provided cloud storage. `reporting_delivery_methods` on capabilities declares supported protocols (SFTP, S3, GCS, Azure Blob). `reporting_bucket` on accounts specifies the destination. Products declare `supports_offline_delivery` in `reporting_capabilities`. File formats: CSV, JSON, Parquet, Avro, and ORC. (#2198, #2205) + +11. **Error Codes and Schema Consistency** — New `GOVERNANCE_DENIED` correctable error code. `context` and `ext` fields added to all request and response schemas across governance, collection, property, SI, and content-standards protocols. `signal_id` is now required on `get_signals` response items. `comply_test_controller` schema flattened from a oneOf union to a flat object with a `scenario` discriminant. Envelope-level `replayed` flag (from idempotency) is now accepted on 15 mutating-tool response schemas that previously rejected it — property-list, collection-list, and governance tools that return replay responses no longer fail schema validation (#2839). Formal `audience-status` enum extracted with explicit lifecycle transitions, paralleling the other lifecycle-bearing resource types (#2836). (#2194) + +12. **Media Buy Lifecycle** — `pending_activation` splits into `pending_creatives` (approved, no creatives assigned) and `pending_start` (ready to serve, waiting for flight date). `MediaBuy.pending_approval` removed; IO approvals are now explicit task-layer objects with their own lifecycle and audit trail — decoupled from media-buy status. New `submitted` branch on `create_media_buy` indicates the seller accepted the payload for processing but has not yet confirmed the order. Top-level `compliance_testing: { scenarios: [...] }` capability block declares `comply_test_controller` support. (#2034, #2270, #2351, #2425) + +13. **TMP: Provider Registration, TMPX Exposure Tracking, and Multi-Identity Match** — New `provider-registration.json` schema formalizes provider endpoints, capabilities, lifecycle status (active/inactive/draining), and timeout budgets. `GET /health` endpoint enables router-side monitoring. TMPX adds exposure tracking with country-partitioned identity and macro connectivity. Identity Match requests now accept an `identities` array (1-3 tokens per request) so publishers can maximize match rate across heterogeneous buyer identity graphs. Router filters per provider and re-signs; RFC 8785 JCS canonicalization eliminates delimiter-injection risk. TMP remains pre-release in 3.0; stable surface targeted for 3.1.0. (#2210, #2079, #2251) + +14. **Operating an Agent, Release Cadence, CHARTER** — New "Operating an Agent" guide for publishers without engineering teams (partner / self-host / build). Named release cadence policy: patch monthly, minor quarterly, major annual if needed. v2 EOL August 1, 2026. Formal `CHARTER.md` linked from README, IPR, and intro. AI disclosure page. Known-limitations and privacy-considerations reference pages. `status: experimental` marker for in-production but not-yet-stable protocol fields; `custom` pricing-model escape hatch on signals. (#2202, #2309, #2311, #2312, #2321, #2329, #2362, #2382, #2422, #2427) + +15. **Trust-surface late hardening** — Two normative tightenings from the external 3.0 security review land as MUST in GA: + - **Idempotency cache insert-rate limiting.** Sellers MUST apply per-`(authenticated_agent, account)` rate limits on idempotency-cache inserts (separate from request rate limits) and return `RATE_LIMITED` with `retry_after` when exceeded. First-deployment ceiling: **60 inserts/sec sustained per agent (3,600/min), burst to 300/sec over rolling 10s windows** — sized against realistic high-volume launch patterns (10 media buys/min × 10 packages × 10 creatives, 3–5× headroom) and consistent with the existing 100k-per-keyid webhook replay cap and 1M-per-keyid request replay cap. Tunable per deployment. Closes a nonce-flood DoS amplification vector. See [security.mdx idempotency § bullet 8](/dist/docs/3.0.13/building/by-layer/L1/security#idempotency). + - **Webhook-registration signing MUST for signing-capable sellers.** Sellers that support request signing MUST reject webhook-registration requests carrying `push_notification_config.authentication` over bearer-only (unsigned) transport, with `request_signature_required`. Structural defense against on-path mutators injecting or stripping the `authentication` block during onboarding — a 9421-signed registration cryptographically commits to the body. Sellers with no signing support keep the log-and-alarm posture. Scoped breakage: buyers that previously registered webhooks with `authentication` against a signing-capable seller over bearer transport must switch to 9421-signed registration. Negative test vector `027-webhook-registration-authentication-unsigned.json` lands with the tightening. + +16. **Error-code vocabulary cleanup** (#2704) — The uniform-response MUST (#2691) forbids sellers from minting custom `*_NOT_FOUND` codes for typed parameters; the spec itself was out of compliance in 12 places. Cleanup aligns the spec with the rule: `PLAN_NOT_FOUND` is promoted to standard vocabulary (used across `report_plan_outcome`, `get_plan_audit_logs`, `check_governance`; recovery via `sync_plans`). Eleven other custom codes (`CHECK_NOT_FOUND`, `CAMPAIGN_NOT_FOUND`, `BRAND_NOT_FOUND`, `STANDARDS_NOT_FOUND`, `FORMAT_NOT_FOUND`, `AGENT_NOT_FOUND`, `SIGNAL_AGENT_SEGMENT_NOT_FOUND`, `SEGMENT_NOT_FOUND`, `AUDIENCE_NOT_FOUND`, `CATALOG_NOT_FOUND`, `EVENT_SOURCE_NOT_FOUND`) collapse to `REFERENCE_NOT_FOUND` with `error.field` naming the failed parameter. Signals auth-uniformity tightened: private signal agents now return `REFERENCE_NOT_FOUND` uniformly for unauthorized accounts (preventing cross-tenant enumeration). None of the 12 codes appeared in JSON schemas — prose-level cleanup only; no schema-enum migration. Sellers returning any of the 11 collapsed codes today MUST switch to `REFERENCE_NOT_FOUND`. + +17. **Schema presence tightenings on governance decisions and catalog sync** (#2612) — Conformant agents unchanged; non-conformant ones now fail at `response_schema` validation instead of a less-obvious downstream `field_present` check. `check-governance-response.json` enforces the spec-described presence rules via `if/then`: `status: conditions` requires `conditions` with `minItems: 1` (a conditions decision with zero conditions is non-actionable), `status: denied` requires `findings` with `minItems: 1` (a denial with no finding gives the buyer nothing to act on), and `status: approved | conditions` requires `expires_at`. `sync-catalogs-response.json` requires `item_count` on `action: created | updated | unchanged` (still omitted on `failed | deleted`). + +18. **Typed discriminators and id-naming consistency** — Three structural tightenings that make the 3.0 wire shape unambiguous for validators and for readers of payloads: + - **`asset_type` discriminator on creative assets** (#2776) — All 14 asset schemas declare `asset_type` as a required `const`, and composite schemas (`core/creative-manifest.json`, `core/creative-asset.json`, `core/offering-asset-group.json`, `creative/list-creatives-response.json`) switch from a 14-branch `anyOf` to `oneOf` + `discriminator: { propertyName: "asset_type" }`. ajv 8 consumers with `discriminator: true` report errors against only the selected branch — a single fixture's lint footprint collapsed from 60+ fingerprints to 2. Payloads that omit `asset_type` now fail validation; some `brief` payloads that were passing under the prior `anyOf` only because ajv matched the `text-asset` branch (e.g. a bare `{ "content": "..." }`) must conform to `core/creative-brief.json` — at minimum `{ "asset_type": "brief", "name": "" }`. + - **`refine[]` entity id naming on `get_products`** (#2775) — Generic `id` replaced with `product_id` under `scope: "product"` and `proposal_id` under `scope: "proposal"`, matching the `_id` convention used elsewhere in the protocol. `action` is now optional and defaults to `"include"`; callers only set it explicitly for `"omit"` / `"more_like_this"` / `"finalize"`. + - **SI `context` → `intent` rename** (#2774, experimental carve-out) — On `si_get_offering` and `si_initiate_session`, the natural-language user-intent field is renamed from `context` to `intent`, freeing `context` to carry the universal opaque-echo object (`/schemas/core/context.json`) — matching every other AdCP subprotocol. `si_terminate_session` was already conformant. Treated as a minor change under the `x-status: experimental` + 6-week notice policy for SI. + - **Governance plan scoping contract** (#2777) — `governance/report-plan-outcome-request.json`, `governance/check-governance-request.json`, and `governance/get-plan-audit-logs-request.json` now state on `plan_id` / `plan_ids` that the plan uniquely scopes account and operator; an explicit `account` field is rejected by `additionalProperties: false`. The rejection is not new, but it is now a readable contract instead of a silent schema error. + +19. **url-asset accepts buyer macro templates** (#2801) — `core/assets/url-asset.json` relaxes `url.format` from `uri` to `uri-template` (RFC 6570 Level 1). This matches the prose spec in `docs/creative/universal-macros.mdx`, which requires buyers to submit tracker URLs with raw AdCP macros like `{SKU}` / `{DEVICE_ID}` / `{MEDIA_BUY_ID}` at sync time — the ad server URL-encodes substituted values at impression time. `uri-template` accepts both plain URIs and Level 1 templates. A new Template Syntax section in `universal-macros.mdx` scopes AdCP to Level 1 — Level 2–4 operators (`{+var}`, `{#var}`, `{.var}`, `{/var}`, `{;var}`, `{?var}`, `{&var}`) are not used. + +Brand schema extensions (`border_radius`, `elevation`, `spacing`, extended color roles, structured font definitions, generic `agents` array, `data_subject_contestation` for Art 22) and minor additions landed throughout the release. A trio of **non-normative `x-` annotations** also shipped to support the storyboard conformance harness — `x-entity` identifies which AdCP entity a field references (media_buy, brand, account, plan, policy, property_list, etc.; #2660), `x-mutates-state` marks request schemas that change observable state independently of whether they require an `idempotency_key` (#2675), and `governance_policy` splits into registry vs. inline variants (#2685). Agents don't validate `x-` fields; they're tooling hints for lints and entity-binding checks. See the [CHANGELOG](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#300) for the full list of minor additions. + +### Breaking Changes + +| Change | rc.3 | 3.0 | +|--------|------|-----| +| `idempotency_key` on mutating requests | Optional | **Required** (UUID v4 in the envelope) | +| `idempotency_key` on webhook payloads | Not standardized (fragile `(task_id, status, timestamp)` dedup) | **Required** on every webhook payload (UUID v4, cryptographically random) | +| Webhook signing | HMAC-SHA256 with shared secret (`push_notification_config.authentication` required) | RFC 9421 Ed25519 profile, baseline-required for sellers emitting webhooks; HMAC is a legacy fallback removed in 4.0 | +| `revocation-notification.notification_id` | Per-payload field name | Renamed to `idempotency_key` for protocol-wide dedup consistency | +| `MediaBuy.pending_approval` status | Present | Removed — IO approval modeled as explicit approval tasks | +| `budget.authority_level` enum | `agent_full \| agent_limited \| human_required` | Removed. Split into `budget.reallocation_threshold` (number) + `plan.human_review_required` (boolean) | +| `inventory-lists` specialism | Present | Renamed to `property-lists`; `collection-lists` is now a separate specialism | +| `domains` compliance taxonomy | `/compliance/{v}/domains/` | Renamed to `/compliance/{v}/protocols/` | +| `audience-sync` parent protocol | Under `governance` | Moved to `media-buy` | +| Capabilities boolean gates | `features.content_standards: true`, `brand.identity: true`, etc. | Removed — object presence is the signal | +| `reporting_capabilities` | Optional on products | Required | +| `pending_activation` status | Single status | Split into `pending_creatives` and `pending_start` | +| `account` on `update_media_buy` | Optional | Required | +| `preview_creative` schema | oneOf union | Flat object with `request_type` discriminant | +| `signal_id` on `get_signals` response | Optional | Required | +| `media_buy.reporting` capability | Present | Removed — use product-level `reporting_capabilities` | +| `governance_context` carrier | Opaque string | Signed JWS (decoded via governance agent JWKS) | +| `content_standards_detail` | Named `content_standards_detail` | Renamed to `content_standards` | +| Geo capability arrays | `supported_geo_levels`, `supported_metro_systems`, `supported_postal_systems` | Removed — use typed objects (`geo_countries`, `geo_regions`, etc.) | +| `comply_test_controller` schema | oneOf union | Flat object with `scenario` discriminant | +| `media_buy_id` in governance | Lifecycle correlator | Removed — use `governance_context` | +| `asset_type` on creative-asset payloads | Inferred via 14-branch `anyOf` | Required `const` on every asset; composite schemas use `oneOf` + `discriminator` | +| `refine[]` entity ids on `get_products` | Generic `id` field | `product_id` under `scope: "product"`, `proposal_id` under `scope: "proposal"`; `action` now optional (default `include`) | +| SI `context` field on `si_get_offering` / `si_initiate_session` | String (user intent) | Renamed to `intent`; `context` now carries the universal opaque-echo object | +| `core/assets/url-asset.json` `url.format` | `uri` (rejected AdCP macros) | `uri-template` (RFC 6570 Level 1) — accepts `{SKU}` / `{DEVICE_ID}` / `{MEDIA_BUY_ID}` at sync time | + +### Migration Notes For rc.3 Adopters + +- **Webhooks** — Migrate from HMAC-SHA256 to RFC 9421 signing. Publish a webhook-signing JWK in your JWKS at `jwks_uri` (JWKS is referenced from `brand.json` `agents[]`) with `adcp_use: "webhook-signing"` on the JWK and a `kid` unique across purposes. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. Every outbound webhook payload must carry an `idempotency_key` matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Listeners must dedupe keyed by sender identity (signing `keyid` under 9421, or HMAC/Bearer credential under legacy) with a 24h minimum TTL. HMAC fallback remains available through 3.x; the full `authentication` object is removed in 4.0. +- **Idempotency** — Generate a fresh key on every mutating request, matching `^[A-Za-z0-9_.:-]{16,255}$` (UUID v4 for Verified). Same key + identical payload on retry → `replayed: true`. Same key + different payload → `IDEMPOTENCY_CONFLICT`. Key older than the seller-declared `replay_ttl_seconds` → `IDEMPOTENCY_EXPIRED` (1h–7d, 24h recommended — no protocol default). Your agent must persist keys across instances. +- **Request signing (optional in 3.0, required for Verified)** — If you plan to claim AdCP Verified, implement RFC 9421 Ed25519 signing per the [signing profile](/dist/docs/3.0.13/building/by-layer/L1/security#request-signing) and declare your signing key via the account surface. Test against `static/compliance/source/test-vectors/request-signing/` and the runner's `signed_requests` harness. +- **Governance context** — Switch from opaque-string `governance_context` to the signed JWS format. Verify using the governance agent's JWKS (resolved via `sync_governance`). Bind signature to `sub` (buyer), `aud` (seller), `phase`, and `exp` before trusting. +- **IO approval** — Remove `MediaBuy.pending_approval` from state filters. Consume approval tasks from the task surface instead. +- **Budget autonomy** — Rewrite any `budget.authority_level` references: `agent_full` → `reallocation_unlimited: true`; `agent_limited` → `reallocation_threshold: `; `human_required` → `plan.human_review_required: true`. +- **Regulated verticals** — For credit, insurance, recruitment, or housing campaigns, declare `policy_categories` and either set `human_review_required: true` or let your governance agent do so automatically. Populate `brand.data_subject_contestation` for Art 22(3) discovery. +- **Specialism claims** — Rename `inventory-lists` → `property-lists`; add `collection-lists` if applicable. Reclassify `audience-sync` under `media-buy` (add `media_buy` to `supported_protocols` if you only declared `governance`). +- **Compliance paths** — Update any references to `/compliance/{v}/domains/` → `/compliance/{v}/protocols/`. +- **Capabilities** — Remove all boolean capability checks (`features.content_standards`, `brand.identity`, `trusted_match.supported`, etc.). Test for object presence instead. See the [prerelease upgrade notes](/dist/docs/3.0.13/reference/migration/prerelease-upgrades#capabilities-model-simplification) for the full field list. +- **Products** — Ensure all products include `reporting_capabilities`. This field is now required. +- **Media buy status** — Replace `pending_activation` with `pending_creatives` and `pending_start`. See [lifecycle states](/dist/docs/3.0.13/media-buy/media-buys#lifecycle-states). +- **update_media_buy** — Pass `account` on all calls, matching `create_media_buy` behavior. +- **preview_creative** — Update request builders from the oneOf union to the flat schema with `request_type` discriminant. +- **Signals** — Include `signal_id` on all signal items in `get_signals` responses. +- **Governance** — Replace `media_buy_id` references with `governance_context`. Handle `GOVERNANCE_DENIED` as a correctable error code. +- **Version negotiation** — Include `adcp_major_version` on requests when interacting with multi-version sellers. +- **Creative asset payloads** — Add `"asset_type": ""` to every asset value in `creative_manifest.assets`, `creative.assets`, `offering_asset_group.items`, and `list_creatives` responses. For `brief` assets, move free-text prompts out of a bare `{ "content": "..." }` shape into `core/creative-brief.json` fields (at minimum `{ "asset_type": "brief", "name": "" }`). For `vast` / `daast` assets, `asset_type` at the root plus the existing nested `delivery_type` discriminator. +- **Product refinement** — Rename `refine[].id` to `refine[].product_id` (scope: product) or `refine[].proposal_id` (scope: proposal). Drop `action` when you mean `include`; keep it only for non-default values (`omit`, `more_like_this`, `finalize`). +- **Sponsored Intelligence** — Rename the user-intent field from `context` to `intent` on `si_get_offering` and `si_initiate_session`. If you echo a universal `context` object, place it under `context` (now typed per `/schemas/core/context.json`). +- **url-asset tracker URLs** — No action needed to accept macros — the validator now permits RFC 6570 Level 1 templates. Do not pre-encode AdCP macros at sync time; the ad server URL-encodes substituted values at impression time. + +### Next steps + +- **Upgrading from v2?** Start with the [migration guide](/dist/docs/3.0.13/reference/migration). +- **Upgrading from a prerelease?** Go to the [prerelease upgrade notes](/dist/docs/3.0.13/reference/migration/prerelease-upgrades). +- **New to AdCP?** Read the [3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) and then [get started](/dist/docs/3.0.13/quickstart). + +--- + +## Version 3.0.0-rc.3 + +**Status:** Release Candidate (superseded by 3.0.0) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) | [SDK support](/dist/docs/3.0.13/building/by-layer/L0/schemas#adcp-3-0-support) + +### What's New + +1. **Trusted Match Protocol (TMP)** — Real-time execution layer for AdCP. 9 schemas, 12 documentation pages, provider discovery on products, typed artifacts for content resolution, and lightweight context matching. Deprecates AXE. + +2. **Order Lifecycle Management** — `confirmed_at` on order creation, cancellation at media buy and package level with `canceled_by` attribution, per-package `creative_deadline`, `valid_actions` for state-aware agents, `revision` for optimistic concurrency, and `include_history` for revision audit trails. 7 new error codes. + +3. **Governance Simplification** — Remove `binding` field from `check_governance` (inferred from discriminating fields), remove `mode` from `sync_plans` (governance agent configuration, not protocol), remove `escalated` status (handled via async task lifecycle). Three terminal statuses: `approved`, `denied`, `conditions`. + +4. **Seller-Assigned IDs** — Remove `buyer_ref`, `buyer_campaign_ref`, and `campaign_ref`. Seller-assigned `media_buy_id` and `package_id` are canonical. `idempotency_key` on all mutating requests. Opaque `governance_context` string replaces structured schema. + +5. **Proposal Lifecycle** — Draft/committed proposal status, finalization via refine action, insertion order signing, and expiry enforcement on `create_media_buy`. Guaranteed products start as draft with indicative pricing. + +6. **Audience Bias Governance** — Structured audience data for fairness validation. New schemas for audience selectors, constraints, policy categories, and restricted attributes (GDPR Article 9). 10 policy category definitions, 8 restricted attribute definitions, 13 seed policies across US, EU, and platform regulations. + +7. **Streaming and Audio Delivery Metrics** — `completed_views` (renamed from `video_completions`), `reach`, `reach_unit`, and `frequency` in aggregated totals. Audio/podcast-native metrics. `reach_unit` co-occurrence constraint on `delivery-metrics.json`. + +8. **Availability Forecasts** — `budget` now optional on `ForecastPoint` for total available inventory. New `availability` forecast range unit. Guaranteed products include availability forecasts with estimated cost. + +9. **Advertiser Industry Taxonomy** — Two-level dot-notation categories on brand manifest and `create_media_buy`. Restricted categories (gambling, cannabis, dating) require explicit declaration. + +10. **Content Standards** — `content_standards` on `get_adcp_capabilities` for pre-buy visibility into local evaluation and artifact delivery capabilities. `sampling` removed from `get_media_buy_artifacts` (configured at creation time). + +11. **Event Source Health** — Optional `health` on event sources (status, match rate, event volume, issues) and `measurement_readiness` on products for buyer event setup evaluation. + +12. **Collection/Installment Extensions** — `special` and `limited_series` fields, installment deadlines with deadline policies, print-capable creative formats with physical units, DPI, bleed, and color space. + +13. **Scoped adagents.json Authorization** — Delegation types, placement governance, signing keys, country and time-window constraints on `authorized_agents`. + +### Breaking Changes + +| Change | rc.2 | rc.3 | +|--------|------|------| +| Buyer references | `buyer_ref`, `buyer_campaign_ref`, `campaign_ref` on requests | Removed — seller-assigned `media_buy_id` and `package_id` are canonical | +| Idempotency | `buyer_ref` as implicit dedup key | Explicit `idempotency_key` on all mutating requests | +| Governance context | Structured `governance-context.json` schema | Signed-JWS `governance_context` string (opaque to forwarders, cryptographically verifiable by auditors — see [security.mdx](/dist/docs/3.0.13/building/by-layer/L1/security#signed-governance-context)) | +| `check_governance` binding | `binding` field on request | Removed — inferred from discriminating fields | +| `sync_plans` mode | `mode` field (audit/advisory/enforce) | Removed — governance agent configuration | +| `check_governance` status | `escalated` as possible status | Removed — use async task lifecycle | +| `get_media_buy_artifacts` | `sampling` parameter on request | Removed — sampling configured at media buy creation | +| FormatCategory | `format-category.json` enum, `type` on Format | Removed — use `assets` array or `asset_types` filter | +| Shows/episodes | `show`, `episode`, `show_id`, `episode_id` | Renamed to `collection`, `installment`, `collection_id`, `installment_id` | + +### Migration Notes For rc.2 Adopters + +- **Buyer references** — Remove `buyer_ref` and `campaign_ref` from request payloads. Use `idempotency_key` for safe retries instead. +- **Governance** — Remove `binding` from `check_governance` calls, `mode` from `sync_plans`, and stop handling `escalated` status. Use async task polling for human review workflows. +- **Artifacts** — Remove `sampling` from `get_media_buy_artifacts` requests. Configure sampling at `create_media_buy` time. +- **Format filtering** — Replace `format_types` filters with `asset_types` on `list_creative_formats` or `channels` on `get_products`. +- **Collections** — Rename `show`/`episode` references to `collection`/`installment` throughout. + +--- + +## Version 3.0.0-rc.2 + +**Status:** Release Candidate | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) + +### What's New + +1. **Brand Protocol Rights Lifecycle** — `get_rights`, `acquire_rights`, `update_rights` tasks for brand licensing. Generation credentials, creative approval webhooks, revocation notifications, and usage reporting. Authenticated webhooks (HMAC-SHA256), actionable vs final rejection convention, DDEX PIE mapping for music licensing, and sandbox tooling for scenario testing. + +2. **Visual Guidelines on Brand Manifest** — Structured `visual_guidelines` field on `brand.json`: photography, graphic style, shapes, iconography, composition, motion, logo placement, colorways, type scale, asset libraries, and restrictions. Enables generative creative systems to produce on-brand assets. + +3. **Collections and Installments** — Content dimension for products representing persistent programs (podcasts, TV series, YouTube channels). `collections` on products references collections declared in an `adagents.json` by domain and collection ID — collection creators can serve as canonical sources regardless of distribution. `collection_targeting_allowed` controls whether buyers can select a subset or get the bundle. Distribution identifiers for cross-seller matching, installment lifecycle states, break-based ad inventory, talent linking, collection relationships, international content ratings. + +4. **Sponsored Intelligence Channel** — `sponsored_intelligence` added to the media channel taxonomy for AI platform advertising (AI assistants, AI search, generative AI experiences). + +5. **Property Governance Integration** — Optional `property_list` parameter on `get_products` for filtering products by governance-evaluated property lists. `property_list_applied` response field confirms filtering. + +6. **Campaign Governance and Policy Registry** — `sync_plans`, `check_governance`, `report_plan_outcome`, and `get_plan_audit_logs` for plan-level governance. Adds audit/advisory/enforce modes, delegated budget authority, seller-side governance checks, portfolio governance, registry-backed shared policies, and `governance_context` for canonical plan extraction. + +7. **Account Model Simplification** — Removed `account_resolution` capability. `require_operator_auth` now determines both auth model and account reference style. + +8. **Creative Workflow Upgrades** — `build_creative` now supports inline preview via `include_preview`, multi-format output via `target_format_ids`, quality tiers for draft vs production output, catalog-driven `item_limit`, and library retrieval using `creative_id` plus optional macro substitution and trafficking context. `preview_creative` also adds quality control parameters. + +9. **Creative Library Protocol Unification** — `list_creatives` and `sync_creatives` now live in the Creative Protocol. Creative agents can advertise `supports_generation`, `supports_transformation`, and `has_creative_library` so buyers can route generation, transformation, and library retrieval intentionally. + +10. **Disclosure Persistence** — Regulatory disclosure requirements can now specify persistence (`continuous`, `initial`, `flexible`) in addition to position and duration, with matching capability declarations on formats. + +11. **Product Discovery and Planning Ergonomics** — Product discovery adds `exclusivity` and `preferred_delivery_types`; products may omit `delivery_measurement`; packages and proposals support per-package `start_time` / `end_time`; and `get_products` now supports `time_budget` with `incomplete` response reporting. + +12. **Accounts and Sandbox Refinements** — `sync_accounts` adds `payment_terms`, sandbox capability moves to the account capability block, and sandbox now participates in the account natural key for implicit account flows. + +13. **Governance Agent Sync** — `sync_governance` task for syncing governance agent endpoints to specific accounts. Supports both explicit accounts (`account_id`) and implicit accounts (`brand` + `operator`) via account references. Replace semantics per call. Governance sync is now a dedicated tool — removed from `sync_accounts` and `list_accounts`. + +### Breaking Changes + +| Change | rc.1 | rc.2 | +|--------|------|------| +| Account resolution | `account_resolution` capability | Removed — `require_operator_auth` determines account model | +| Creative library operations | `list_creatives` and `sync_creatives` documented under Media Buy | Creative library tasks now live in the Creative Protocol | +| Sandbox capability | `media_buy.features.sandbox` | `account.sandbox` | +| DOOH flat rate parameters | `flat_rate.parameters` without discriminator | `flat_rate.parameters.type: "dooh"` required when parameters are present | +| delete_content_standards | Documented task | Removed — archive standards via `update_content_standards` instead | +| get_property_features | Standalone task | Removed — use property list filters and `get_adcp_capabilities` for feature discovery | +| Governance agent sync | `governance_agents` on `sync_accounts` request and `list_accounts` request | Moved to dedicated `sync_governance` task | + +### Migration Notes For rc.1 Adopters + +- **Creative task routing** — If you adopted 3.0.0-rc.1, update any assumptions that creative library operations are Media Buy tasks. `list_creatives` and `sync_creatives` are Creative Protocol operations in rc.2, even when a sales agent implements them on the same endpoint. +- **Capability discovery** — Replace reads of `account_resolution` with `require_operator_auth`, and read sandbox support from `account.sandbox` instead of `media_buy.features.sandbox`. +- **Creative request/response handling** — `build_creative` can now return inline previews, multiple manifests, or a library-resolved manifest. Clients that assumed only single-format manifest-in / manifest-out behavior should update their response handling. +- **DOOH validation** — Existing v3 DOOH `flat_rate` integrations must add `type: "dooh"` inside `parameters` when those parameters are provided. + +### Other Updates + +- **Certification program** — Three-tier certification (Basics, Practitioner, Specialist) taught by Addie through interactive chat, with vibe coding build projects for non-technical participants. +- **Documentation** — Illustrated protocol walkthroughs, buy-side guides, FAQ expansion, schema-compliant examples, and collapsed sidebar navigation. + +--- + +## Version 3.0.0-rc.1 + +**Status:** Release Candidate | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) + +### What's New + +1. **Keyword Targeting** — `keyword_targets` and `negative_keywords` on the targeting overlay for search and retail media. Per-keyword bid prices with broad, phrase, and exact match types. Incremental management via `keyword_targets_add` and `keyword_targets_remove` on package updates. `by_keyword` breakdown in delivery reporting. + +2. **Optimization Goals Redesign** — `optimization_goal` (singular) replaced by `optimization_goals` (array). Discriminated union on `kind`: `metric` for seller-native delivery metrics or `event` for conversion tracking. Priority ordering for multi-goal packages. New metric types: `engagements`, `follows`, `saves`, `profile_visits`. View duration configuration for video completion goals. + +3. **Reach Optimization** — `reach` as a metric optimization goal with `reach_unit` (individuals, households, devices, accounts, cookies, custom) and optional `target_frequency` band (min/max/window). Products declare `supported_reach_units` when reach is in their supported metrics. + +4. **Expanded Frequency Cap** — `frequency_cap` now supports `max_impressions` + `per` + `window` in addition to the existing `suppress_minutes`. + +5. **Signal Pricing Models** — Discriminated union with three pricing models: `cpm`, `percent_of_media` (with optional `max_cpm`), and `flat_fee`. Structured `pricing_options` array replaces the legacy pricing object on signals. `pricing_option_id` on `activate_signal`. `idempotency_key` on `report_usage` for preventing double-billing. + +6. **Dimension Breakdowns** — Opt-in reporting dimensions on `get_media_buy_delivery`: `by_geo`, `by_device_type`, `by_device_platform`, `by_audience`, `by_placement` — each with truncation flags. New `reporting_dimensions` request parameter with per-dimension `limit` and `sort_by` controls. Capability declaration at seller level (`get_adcp_capabilities` → `media_buy.reporting`) and product level (`reporting_capabilities`). + +7. **Device Type Targeting** — New `device_type` field (form factor: desktop, mobile, tablet, ctv, dooh, unknown) distinct from existing `device_platform` (OS). With `device_type_exclude` for negative targeting. Declared in `get_adcp_capabilities` targeting capabilities. + +8. **Deliver-to Flattening** — The nested `deliver_to` object in `get_signals` request replaced with two top-level fields: `destinations` and `countries`. Simplifies queries where the caller is querying a platform's own signal agent. + +9. **Metric Optimization Capabilities** — Products declare `metric_optimization` block with `supported_metrics`, `supported_view_durations`, and `supported_targets`. `max_optimization_goals` on products. Conversion tracking declares `supported_targets`. `multi_source_event_dedup` flag in `get_adcp_capabilities`. + +10. **Swiss & Austrian Postal Codes** — `ch_plz` and `at_plz` added to the postal-system enum, with corresponding updates to `get_adcp_capabilities`. + +11. **Brand Identity Unification** — `brand-manifest.json` deleted. Task schemas now reference brands by `{ domain, brand_id }` (BrandRef) instead of passing inline manifests. Brand data is resolved from `brand.json` or the registry at execution time. + +12. **Delivery Forecasts on Products** — `estimated_exposures` replaced with structured `forecast` field using the `DeliveryForecast` type. Products now return time periods, metric ranges, and methodology context during discovery. + +13. **Proposal Refinement via Buying Mode** — `proposal_id` removed from `get_products` request. Refinement now uses `buying_mode: "refine"` with a typed `refine` array (see item 21). 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. + +14. **First-Class Catalogs** — `sync_catalogs` task with 13 catalog types (structural and industry-vertical). Formats declare `catalog_requirements`. Catalogs include `conversion_events` and `content_id_type` for attribution alignment. + +15. **New Tasks** — `get_media_buys` for operational campaign monitoring, `get_creative_features` for creative governance, `sync_audiences` for CRM-based audience management. + +16. **Buying Mode** — `buying_mode` on `get_products` with three modes: `brief` (natural language discovery), `wholesale` (full catalog), and `refine` (typed change-requests on previous results). Now required on all `get_products` calls. + +17. **Sandbox Mode** — `sandbox` protocol parameter on all tasks for testing without side effects. + +18. **Creative Brief Type** — `CreativeBrief` type on `build_creative` request for structured creative direction. + +19. **Campaign Reference** — `campaign_ref` field for cross-operation campaign grouping across multiple media buys. + +20. **Geo Proximity Targeting** — `geo_proximity` targeting overlay for point-based proximity targeting. Three methods: `travel_time` isochrones (e.g., "within 2hr drive of Düsseldorf"), `radius` (e.g., "within 30km of Heathrow"), and pre-computed `geometry` (buyer provides a GeoJSON polygon). Structured capability declaration in `get_adcp_capabilities` with per-method and transport mode support. + +21. **Typed Refinement with Seller Acknowledgment** — `refine` redesigned from nested object to typed change-request array with `scope` discriminator (`request`, `product`, `proposal`). Field renames: `product_id`/`proposal_id` → `id`, `notes` → `ask`. Seller responses include `refinement_applied` — a positionally-matched array with per-ask `status` (`applied`, `partial`, `unable`) and notes. + +22. **AI Provenance and Disclosure** — `provenance` object on creative manifests, assets, and artifacts for AI content transparency. IPTC-aligned `digital_source_type` enum (9 values), `ai_tool` metadata, `human_oversight` levels, C2PA soft references via `manifest_url`, regulatory `disclosure` with per-jurisdiction requirements (EU AI Act, California SB 942), and third-party `verification` results. `provenance_required` on creative policy for seller enforcement. + +23. **Creative Compliance** — `compliance` object on creative briefs with `required_disclosures` (structured items with text, position, jurisdictions, regulation, minimum duration) and `prohibited_claims`. New `disclosure-position` enum with 8 standard positions. Formats declare `supported_disclosure_positions`. `supports_compliance` capability flag replaces `supports_brief`. + +24. **Manifest Unification** — Creative manifest model unified to `format_id` + `assets`. Briefs and catalogs become proper asset types (`brief`, `catalog`) within the assets map. All asset reference lists across creative-manifest, creative-asset, and list-creatives-response aligned to 14 asset types with consistent `anyOf` validation. + +25. **Structured Error Recovery** — `recovery` field on errors with three classifications: `transient` (retry after delay), `correctable` (fix request and resend), `terminal` (requires human action). 18 standard error codes (`INVALID_REQUEST`, `RATE_LIMITED`, `POLICY_VIOLATION`, `PRODUCT_UNAVAILABLE`, etc.) with recovery mappings in the new `error-code` enum. + +26. **Agent Ergonomics** — `fields` parameter on `get_products` for response projection (24 projectable fields). `include_package_daily_breakdown` opt-in on delivery reporting. Request-side `attribution_window` for cross-platform normalization. Buy-level `start_time`/`end_time` on `get_media_buys`. `supported_pricing_models` on seller capabilities. Audience metadata: `description`, `audience_type` (crm, suppression, lookalike_seed), `tags`, and `total_uploaded_count` for match rate calculation. + +27. **Signal Deactivation** — `action` field on `activate_signal` with `activate` (default) and `deactivate` values for compliance-driven signal removal. + +28. **Signal Metadata** — `categories` (valid values for categorical signals) and `range` (min/max for numeric signals) on signal entries in `get_signals` responses. + +29. **Media Buy Rejection** — `rejected` status added to media-buy-status enum with `rejection_reason` on the MediaBuy object. + +30. **Idempotency** — `idempotency_key` on `update_media_buy` and `sync_creatives` for at-most-once execution. `idempotency_key` added to `create_media_buy` for at-most-once creation. + +### Breaking Changes + +| Change | beta.3 | rc.1 | +|--------|--------|------| +| Brand identity | Inline `brand_manifest` object | `brand` (BrandRef: `{ domain, brand_id }`) — resolved at execution time | +| Product exposure estimates | `estimated_exposures` (integer) | `forecast` (DeliveryForecast object) | +| Proposal refinement | `proposal_id` on `get_products` request | Removed — use `buying_mode: "refine"` with typed `refine` array | +| Optimization goals | `optimization_goal` (singular object) | `optimization_goals` (array of discriminated union) | +| AudienceMember identity | `external_id` in uid-type enum | `external_id` is required top-level field, removed from enum | +| Signals deliver_to | Nested `deliver_to` object | Top-level `destinations` and `countries` | +| Signals pricing | `pricing: { cpm }` | Structured `pricing_options[]` array | +| report_usage | `kind` and `operator_id` fields | Both removed | +| Refinement model | `refine` object with `overall`/`products`/`proposals` | `refine` array of typed change-requests with `scope` discriminator | +| Creative assignments | `{ creative_id: package_id[] }` map | Typed array with `creative_id`, `package_id`, `weight`, `placement_ids` | +| Signals account | String `account_id` | `account` (AccountReference object) | +| Signals deployments | `deployments` field | `destinations` (renamed) | +| Package catalogs | `catalog` (single object) | `catalogs` (array) | +| buying_mode | Not present | Required — three modes: `brief`, `wholesale`, `refine` | +| Creative brief delivery | Top-level `creative_brief` on `build_creative` | `brief` asset type in manifest `assets` map | +| Creative capability | `supports_brief` | `supports_compliance` | + +### Other Changes + +- `audience-source` enum for breakdown-level audience attribution +- `device-type` enum (desktop, mobile, tablet, ctv, dooh, unknown) for form factor targeting and breakdown reporting +- `sort-metric` enum for controlling breakdown sort order +- `geo-breakdown-support` schema for declaring per-geography breakdown capabilities +- Keyword targeting capability flags in `get_adcp_capabilities` +- `reporting` object in `get_adcp_capabilities` for declaring dimension breakdown support +- `geo_proximity` capability object in `get_adcp_capabilities` for declaring supported proximity methods and transport modes +- `error-code` enum with 18 standard error codes and recovery classifications +- `disclosure-position` enum (8 standard positions for regulatory disclosures) +- `digital-source-type` enum (9 IPTC-aligned values for AI content classification) +- `provenance` core object for AI content transparency across creative schemas +- `consent-basis` enum extracted from `sync_audiences` (consent, legitimate_interest, contract, legal_obligation) +- `date-range` and `datetime-range` core types replacing inline period objects +- Property list filters relaxed: `countries_all` and `channels_any` no longer required +- Signal `categories` and `range` metadata on `get_signals` responses +- `rejected` media buy status with `rejection_reason` +- `idempotency_key` on `update_media_buy` and `sync_creatives` +- Removed `not:{}` patterns from 7 response schemas for Python codegen compatibility + +--- + +## Version 3.0.0-beta.3 + +**Status:** Beta | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) + +### What's New + +1. **Delivery Forecasting** - Predict campaign performance before committing budget. New `DeliveryForecast` type with budget curves, forecast methods (estimate, modeled, guaranteed), daypart targeting windows, and GRP demographic notation. Forecasts attach to product allocations and proposals, enabling budget curve analysis across spend levels. + +2. **Brand Protocol** - Brand discovery and identity resolution via `brand.json`. Four manifest variants (authoritative redirect, house redirect, brand agent, house portfolio) with builder tools, registry, and admin UI. Brands declare `authorized_operators` to control which agents can represent them. + +3. **Account Management** - `sync_accounts` task lets agents declare brand portfolios to sellers with upsert semantics. Account capabilities in `get_adcp_capabilities` describe billing requirements and operator authorization. Two billing models (operator, agent) with account lifecycle (active, pending_approval, payment_required, suspended, closed). `account_id` is optional on `create_media_buy` — single-account agents can omit it. + +4. **Commerce Media** - Catalog-driven product discovery (`catalog` on `get_products`), catalog-driven packages, per-catalog-item delivery reporting (`by_catalog_item`), store catchment targeting, and `catalog_types` on products. Cross-retailer GTIN matching via catalog selectors. Commerce attribution metrics (`roas`, `new_to_brand_rate`) in delivery response. See the [Commerce Media Guide](/dist/docs/3.0.13/media-buy/commerce-media) and [Catalogs](/dist/docs/3.0.13/creative/catalogs). + +5. **Creative Delivery Reporting** - Per-creative metrics breakdown within `by_package` in delivery responses. New `get_creative_delivery` task on creative agents for variant-level delivery data with manifests. Three variant tiers: standard (1:1), asset group optimization, and generative creative. Format-level `reported_metrics` declare which metrics each format can provide. + +6. **CPA & TIME Pricing Models** - Two new pricing models. CPA (Cost Per Acquisition) for outcome-based campaigns — covers CPO, CPL, CPI use cases differentiated by `event_type`. TIME for sponsorship-based advertising where price scales with duration (hour, day, week, month) with optional min/max constraints. + +7. **Conversion Tracking** - New events protocol with `sync_event_sources` and `log_event` tasks for attribution and measurement. + +8. **Signal Catalog** - Data providers become first-class members with signal definitions, categories, targeting schemas, and value types. New schemas for signal discovery and integration into products. + +9. **Cursor-Based Pagination** - All list operations (`list_creatives`, `tasks_list`, `list_property_lists`, `get_property_list`, `get_media_buy_artifacts`) standardized on cursor-based pagination with shared `pagination-request.json` and `pagination-response.json` schemas. + +10. **Accessibility in Creative Formats** - Two-layer accessibility model. Format-level `wcag_level` (A/AA/AAA) and `requires_accessible_assets` flag. Asset-level metadata for inspectable assets (alt text, captions, transcripts) and self-declared properties for opaque assets (keyboard navigable, motion control). Buyers can filter by `wcag_level` in `list_creative_formats`. + +11. **Targeting Restrictions & Geo Exclusion** - Functional restriction overlays for age (with verification methods), device platform (Sec-CH-UA-Platform values), and language localization. Geographic exclusion fields (`geo_countries_exclude`, `geo_regions_exclude`, `geo_metros_exclude`, `geo_postal_areas_exclude`) enable RCT holdout groups and regulatory exclusions. + +12. **Typed Asset Requirements** - Discriminated union schemas for all 12 asset types (image, video, audio, text, markdown, HTML, CSS, JavaScript, VAST, DAAST, URL, webhook) using `asset_type` as discriminator. + +13. **Universal Macros** - `universal-macro.json` enum defining all 54 standard ad-serving macros, including 6 new additions: `GPP_SID`, `IP_ADDRESS`, `STATION_ID`, `COLLECTION_NAME`, `INSTALLMENT_ID`, `AUDIO_DURATION`. + +14. **Brand Manifest Improvements** - Structured tone object with `voice`, `attributes`, `dos`, `donts` fields. Logo objects gain `orientation`, `background`, `variant`, `usage` fields. + +### Breaking Changes + +| Change | beta.2 | beta.3 | +|--------|--------|--------| +| Pagination | `limit`/`offset` | Cursor-based `pagination` object | +| Brand manifest tone | `string \| object` | Object only with structured fields | + +### Other Changes + +- `optimization_goals` on package requests — buyers can specify conversion and metric optimization goals when creating or updating media buys +- Attribution window metadata on delivery response: `click_window_days`, `view_window_days`, and attribution `model` (last_touch, first_touch, linear, time_decay, data_driven) +- Channel fields on property and product schemas: `supported_channels` and `channels` referencing Media Channel Taxonomy enum +- `account_id` added to `get_media_buy_delivery` and `get_media_buy_artifacts` requests +- `date_range_support` in reporting capabilities +- `minItems: 1` on request-side arrays for stricter validation +- `FormatCategory` enum and `type` field removed from Format objects (use `assets` array instead) +- `format_id` optional in `preview_creative` (falls back to `creative_manifest.format_id`) +- `selection_mode` on repeatable asset groups to distinguish sequential (carousel) from optimize (asset pool) behavior +- Session ID fallback recommendation for MCP agent `context_id` + +--- + +## Version 3.0.0-beta.2 + +**Status:** Beta | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#300-beta2) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) + +Building on beta.1, this release adds account-level billing, property targeting controls, CTV technical specs, and agent-driven UI rendering for Sponsored Intelligence. + +### What's New + +1. **Accounts & Agents** - AdCP now distinguishes Brand (whose products are advertised), Account (who gets billed), and Agent (who places the buy). New `account_id` field on media buys, product queries, and creative operations enables multi-account billing with rate cards and credit limits. + +2. **Property Targeting** - Products can declare `property_targeting_allowed` to let buyers target a subset of publisher properties. When enabled, buyers pass a `property_list` in their targeting overlay and the package runs on the intersection. + +3. **A2UI for Sponsored Intelligence** - Sponsored Intelligence sessions now support agent-driven UI rendering via MCP Apps, enabling rich interactive experiences within AI assistants. + +4. **CTV & Streaming Constraints** - Video formats gain technical constraint fields for frame rate, HDR, GOP structure, and moov atom position. Audio formats add codec, sampling rate, channel layout, and loudness normalization (LUFS/true peak) fields. + +5. **Creative Protocol Discovery** - `get_adcp_capabilities` now includes `"creative"` in `supported_protocols`, letting agents discover creative services at runtime. + +### Schema Changes + +- New `account.json`, `list-accounts-request.json`, `list-accounts-response.json` schemas +- `account_id` added to `create-media-buy-request`, `get-products-request`, `sync-creatives-request`, and their responses +- Shared `price-guidance.json` schema extracted to fix duplicate type generation across pricing options +- `property_targeting_allowed` and `property_list` fields added to product and targeting overlay schemas +- Video/audio asset schemas extended with CTV technical constraint fields + +### Removed + +| Removed | Replacement | +|---------|-------------| +| `list_property_features` task | `get_adcp_capabilities` | +| `list_authorized_properties` task | `get_adcp_capabilities` portfolio section | +| `adcp-extension.json` schema | `get_adcp_capabilities` task | +| `assets_required` format field | `assets` array with `required` boolean | +| `preview_image` format field | `format_card` object | + +--- + +## Version 3.0.0-beta.1 + +**Status:** Beta | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#300-beta1) | [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) + + +**This is a beta release.** While the API is stable for testing, breaking changes may occur before the final 3.0.0 release. We encourage early adopters to test and provide feedback. + + +### What's New + +Version 3.0.0 is a **major release** that expands AdCP beyond media buying into governance, brand suitability, and conversational commerce. See the [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) for detailed upgrade instructions. + +**🎯 Key Themes:** + +1. **Media Channel Taxonomy** - Complete overhaul from 9 format-oriented channels to 19 planning-oriented channels that reflect how buyers allocate budgets. See [Media Channel Taxonomy](/dist/docs/3.0.13/reference/media-channel-taxonomy). + +2. **Governance Protocol** - New protocol domain for property lists, content standards, and brand suitability evaluation with collaborative calibration workflows. + +3. **Sponsored Intelligence Protocol** - Conversational brand experiences in AI assistants. Defines how hosts invoke brand agent endpoints for rich engagement without breaking conversational flow. See [Sponsored Intelligence](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol). + +4. **Protocol-Level Capability Discovery** - `get_adcp_capabilities` task replaces agent card extensions, providing runtime discovery of capabilities, supported protocols, and geo targeting systems. + +5. **Creative Assignment Weighting** - Replace simple creative ID arrays with weighted assignments supporting traffic allocation and placement targeting. + +6. **Global Geo Targeting** - Structured geographic targeting with named systems (Nielsen DMA, UK ITL, Eurostat NUTS2, etc.) for international markets. + +### Breaking Changes Summary + +| Change | v2.x | v3.x | +|--------|------|------| +| Channels | 9 values | 19 planning-oriented values | +| Creative assignment | `creative_ids: [...]` | `creative_assignments: [{...}]` | +| Metro targeting | `geo_metros: ["501"]` | `geo_metros: [{ system, code }]` | +| Postal targeting | `geo_postal_codes` | `geo_postal_areas` with system | +| Asset discovery | `assets_required: [...]` | `assets: [{ asset_id, required }]` | + +See [AdCP 3.0 overview](/dist/docs/3.0.13/reference/whats-new-in-v3) for detailed before/after examples and migration steps. + +### New Protocol Domains + +#### Governance Protocol + +Brand suitability and inventory curation: + +- **Property Lists** - `create_property_list`, `get_property_list`, `update_property_list`, `delete_property_list`, `list_property_lists` +- **Content Standards** - `create_content_standards`, `get_content_standards`, `update_content_standards`, `calibrate_content`, `validate_content_delivery` +- **Product Filtering** - Pass property lists to `get_products` for compliant inventory discovery + +#### Sponsored Intelligence Protocol + +Conversational brand experiences: + +- **Session Management** - `si_get_offering`, `si_initiate_session`, `si_send_message`, `si_terminate_session` +- **Capability Negotiation** - Brands declare modalities (voice, video, avatar), hosts respond with supported capabilities +- **Commerce Handoff** - Seamless transition to AdCP for transactions + +See [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) for complete documentation. + +### New Features + +- **`get_adcp_capabilities` task** - Runtime capability discovery replacing agent card extensions +- **Unified asset discovery** - `assets` array with `required` boolean for full asset visibility +- **Property list filtering** - Pass governance lists to `get_products` for brand-safe inventory + +### 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) | + +### Quick Migration Checklist + +- [ ] Update channel enum values ([taxonomy guide](/dist/docs/3.0.13/reference/media-channel-taxonomy)) +- [ ] Replace `creative_ids` with `creative_assignments` +- [ ] Add system specification to metro/postal targeting +- [ ] Implement `get_adcp_capabilities` task +- [ ] Update format parsing to use `assets` array + +**[View AdCP 3.0 overview →](/dist/docs/3.0.13/reference/whats-new-in-v3)** + +--- + +## Version 2.5.0 + +**Released:** November 2025 | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#250) + +### What's New + +Version 2.5.0 is a **developer experience and API refinement** release that significantly improves type safety, schema infrastructure, and creative workflow performance. This release prepares AdCP for production-scale integrations with better TypeScript/Python code generation, stricter validation semantics, and flexible schema versioning. + +**🎯 Key Themes:** + +1. **Type Safety & Code Generation** - Comprehensive discriminator fields throughout the protocol enable excellent TypeScript/Python type inference and eliminate ambiguous union types. + +2. **Batch Creative Previews** - Generate previews for up to 50 creatives in a single API call with optional direct HTML embedding, reducing preview generation time by 5-10x. + +3. **Schema Infrastructure** - Build-time schema versioning with semantic paths (`/schemas/2.5.0/`, `/schemas/v2/`, `/schemas/v2.5/`) enables version pinning and automatic minor version tracking. + +4. **API Consistency** - Atomic response semantics (success XOR error) and standardized webhook payloads eliminate ambiguity and improve reliability. + +5. **Signal Protocol Refinement** - Activation keys returned per deployment with permission-based access, enabling proper multi-platform signal activation. + +6. **Template Formats** - Dynamic creative sizing with `accepts_parameters` enables formats that accept runtime dimensions, durations, and other parameters. + +7. **Enhanced Product Discovery** - Structured filters with date ranges, budget constraints, country targeting, and channel filtering improve product search precision. + +### Key Enhancements + +#### Type Safety & Code Generation +- **Discriminator fields** added to all discriminated union types (destinations, pricing, property selectors, preview requests/responses) +- **Atomic response semantics** - All task responses now use strict success XOR error patterns with `oneOf` discriminators +- **Explicit type declarations** on all const fields for proper TypeScript literal type generation +- **31 new enum schemas** extracted from inline definitions for better reusability + +#### Schema Infrastructure +- **Build-time versioning** - Schemas now support semantic version paths (`/schemas/2.5.0/`), major version aliases (`/schemas/v2/`), and minor version aliases (`/schemas/v2.5/`) +- **Consistent media buy responses** - Both `create_media_buy` and `update_media_buy` now return full Package objects +- **Standardized webhook payload** - Protocol envelope at top-level with task data under `result` field + +#### Product Discovery +- **Structured filters** - Extracted filter objects into separate schemas (`product-filters.json`, `creative-filters.json`, `signal-filters.json`) +- **Enhanced filtering** - Date ranges (`start_date`, `end_date`), budget ranges with currency, country targeting, and channel filtering +- **Full enum support** - Filters now accept complete enum values without artificial restrictions + +#### Signal Protocol +- **Activation keys** - `activate_signal` now returns deployment-specific activation keys (segment IDs, key-value pairs) based on authenticated permissions +- **Consistent terminology** - Standardized on "deployments" throughout signal requests and responses + +#### Creative Protocol +- **Batch preview support** - Preview multiple creatives in one request (`preview_creative` supports 1-50 items) +- **Direct HTML embedding** - Responses can include raw HTML for iframe-free rendering +- **Simplified brand manifest** - Single required field (`name`) eliminates duplicate type generation +- **Template formats** - `accepts_parameters` field enables dynamic formats (e.g., display_[width]x[height], video_[duration]s) +- **Inline creative updates** - `sync_creatives` task provides upsert semantics for updating creatives in existing campaigns + +#### Documentation & Testing +- **Testable documentation** - All code examples can be validated against live schemas +- **Client library prominence** - NPM badges and installation instructions in intro +- **Fixed 389 broken links** across 50 documentation files + +### Migration Guide + +#### Discriminator Fields (Breaking) + +Many schemas now require explicit discriminator fields. Update your code to include these fields: + +**Signal Destinations:** +```json +// Before +{ + "platform_id": "ttd" +} + +// After +{ + "type": "platform", + "platform_id": "ttd" +} +``` + +**Property Selectors:** +```json +// Before +{ + "publisher_domain": "dailyplanet.com", + "property_ids": ["cnn_ctv_app"] +} + +// After +{ + "publisher_domain": "dailyplanet.com", + "selection_type": "by_id", + "property_ids": ["cnn_ctv_app"] +} +``` + +**Pricing Options:** +```json +// Before +{ + "pricing_model": "cpm", + "rate": 12.50 +} + +// After (fixed pricing uses fixed_price field) +{ + "pricing_model": "cpm", + "fixed_price": 12.50 +} +``` + +#### Webhook Payload Structure (Breaking) + +Webhook payloads now use protocol envelope at top-level: + +**Before:** +```json +{ + "task_id": "task_123", + "status": "completed", + "media_buy_id": "mb_456", + "packages": [...] +} +``` + +**After:** +```json +{ + "task_id": "task_123", + "task_type": "create_media_buy", + "status": "completed", + "timestamp": "2025-11-21T10:30:00Z", + "result": { + "media_buy_id": "mb_456", + "packages": [...] + } +} +``` + +#### Signal Activation Response (Breaking) + +`activate_signal` response changed from single key to deployments array: + +**Before:** +```json +{ + "activation_key": "segment_123" +} +``` + +**After:** +```json +{ + "deployments": [ + { + "destination": {"type": "platform", "platform_id": "ttd"}, + "activation_key": "segment_123", + "status": "active" + } + ] +} +``` + +#### Template Formats + +Formats can now accept parameters for dynamic sizing: + +**Template Format Definition:** +```json +{ + "format_id": {"agent_url": "https://creative.adcontextprotocol.org", "id": "display_static"}, + "accepts_parameters": ["dimensions"], + "renders": [{ + "role": "primary", + "parameters_from_format_id": true + }] +} +``` + +The `parameters_from_format_id: true` flag indicates dimensions come from the format_id at usage time. + +**Usage (parameterized format_id):** +```json +{ + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_static", + "width": 300, + "height": 250 + } +} +``` + +This creates a specific 300x250 variant of the display_static template. + +#### Enhanced Product Filtering + +New structured filters in `get_products`: + +```json +{ + "filters": { + "start_date": "2026-01-01", + "end_date": "2026-03-31", + "budget_range": { + "min": 10000, + "max": 50000, + "currency": "USD" + }, + "countries": ["US", "CA"], + "channels": ["display", "ctv"] + } +} +``` + +#### Schema Versioning + +New version paths available: +- `/schemas/2.5.0/` - Exact version +- `/schemas/v2.5/` - Latest 2.5.x patch (auto-updates for patch releases) +- `/schemas/v2/` - Latest 2.x release (auto-updates for minor/patch) +- `/schemas/3.0.13/` - Backward compat alias (same as v2) + +### Breaking Changes + +- **Discriminator fields required** in destinations, property selectors, pricing options, and preview requests +- **Webhook payload structure** - Task data moved under `result` field; `domain` no longer required +- **Signal activation response** - Changed from `activation_key` string to `deployments` array +- **Removed legacy creative fields** - `media_url`, `click_url`, `duration` removed from `list_creatives` response + +### Non-Breaking Additions + +- Application `context` object (optional) in all task requests +- `product_card` and `format_card` fields (optional) for visual UI support +- Batch preview mode in `preview_creative` (backward compatible) +- Package pricing fields in delivery reporting (already documented, now schema-enforced) +- Minor version symlinks (`/schemas/v2.5/`) + +--- + +## Version 2.3.0 + +**Released:** October 2025 | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#230) + +### What's New + +**Publisher-Owned Property Definitions** - Properties are now owned by publishers and referenced by agents, following the IAB Tech Lab sellers.json model. This eliminates duplication and creates a single source of truth for property information. + +**Placement Targeting** - Products can now define multiple placements (e.g., homepage banner, article sidebar), and buyers can assign different creatives to each placement within a product purchase. + +**Simplified Budgets** - Budget is now only specified at the package level, enabling mixed-currency campaigns and eliminating redundant aggregation at the media buy level. + +### Migration Guide + +#### Publisher-Owned Properties + +**Before:** +```json +{ + "properties": [{ + "publisher_domain": "dailyplanet.com", + "property_name": "CNN CTV App", + "property_tags": ["ctv", "premium"] + }] +} +``` + +**After:** +```json +{ + "publisher_properties": [ + { + "publisher_domain": "dailyplanet.com", + "property_tags": ["ctv"] + } + ] +} +``` + +Buyers now fetch property definitions from `https://dailyplanet.com/.well-known/adagents.json`. + +#### Remove Media Buy Budget + +**Before:** +```json +{ + "budget": 50000, + "packages": [...] +} +``` + +**After:** +```json +{ + "packages": [ + {"package_id": "p1", "budget": 30000}, + {"package_id": "p2", "budget": 20000} + ] +} +``` + +Budget is specified per package only. + +### Breaking Changes + +- `properties` field in products → `publisher_properties` +- `list_authorized_properties` returns `publisher_domains` array +- Removed `budget` from create_media_buy/update_media_buy requests + +--- + +## Version 2.2.0 + +**Released:** October 2025 | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#220) + +### What's New + +**Build Creative Alignment** - The `build_creative` task now follows a clear "manifest-in → manifest-out" transformation model with consistent parameter naming. + +### Migration Guide + +**Before:** +```json +{ + "source_manifest": {...}, + "promoted_offerings": [...] +} +``` + +**After:** +```json +{ + "creative_manifest": { + "format_id": {...}, + "assets": { + "promoted_offerings": [...] + } + } +} +``` + +### Breaking Changes + +- `build_creative` parameter renamed: `source_manifest` → `creative_manifest` +- Removed `promoted_offerings` as top-level parameter (now in manifest assets) + +--- + +## Version 2.1.0 + +**Released:** January 2025 | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#210) + +### What's New + +**Simplified Asset Schema** - Separated asset payload schemas from format requirement schemas, eliminating redundancy. Asset types are now determined by format specifications rather than declared in manifests. + +### Migration Guide + +**Before:** +```json +{ + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.example.com/banner.jpg", + "width": 300, + "height": 250 + } + } +} +``` + +**After:** +```json +{ + "assets": { + "banner_image": { + "url": "https://cdn.example.com/banner.jpg", + "width": 300, + "height": 250 + } + } +} +``` + +### Breaking Changes + +- Removed `asset_type` field from creative manifest payloads +- Schema paths changed: `/creative/asset-types/*.json` → `/core/assets/*-asset.json` +- Constraint fields moved from asset payloads to format specifications + +--- + +## Version 2.0.0 + +**Released:** October 2025 | [Full Changelog](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#200) + +### What's New + +First production release of the Advertising Context Protocol with: + +- **8 Media Buy Tasks** - Complete workflow from product discovery to delivery reporting +- **3 Creative Tasks** - AI-powered creative generation and management +- **2 Signals Tasks** - First-party data integration +- **Standard Formats** - Industry-standard display, video, and native formats +- **Multi-Protocol Support** - Works with MCP and A2A + +### Core Features + +- Natural language product discovery with brief-based targeting +- Asynchronous task management with human-in-the-loop approvals +- JSON Schema validation for all requests and responses +- Publisher-owned property definitions via `.well-known/adagents.json` +- Comprehensive format specifications with asset requirements + +--- + +## Versioning and deprecation + +See [Versioning & Governance](/dist/docs/3.0.13/reference/versioning) for the canonical version tiers, release cadence, 3.x stability guarantees, and deprecation policy. See the [v2 sunset page](/dist/docs/3.0.13/reference/v2-sunset) for the v2 end-of-life timeline. + +--- + +## Additional Resources + +- **Technical Changelog** - [CHANGELOG.md](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md) +- **GitHub Releases** - [Release Archive](https://github.com/adcontextprotocol/adcp/releases) +- **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 diff --git a/dist/docs/3.0.13/reference/roadmap.mdx b/dist/docs/3.0.13/reference/roadmap.mdx new file mode 100644 index 0000000000..c1c851d49f --- /dev/null +++ b/dist/docs/3.0.13/reference/roadmap.mdx @@ -0,0 +1,111 @@ +--- +title: Roadmap +description: "AdCP protocol roadmap: RFCs, epics, and development milestones tracked on our public GitHub Project board." +"og:title": "AdCP — Roadmap" +--- + +The AdCP roadmap is a public [GitHub Project board](https://github.com/orgs/adcontextprotocol/projects/1) tracking protocol RFCs and epics across all domains. It shows what we're exploring, what's accepted, what's in progress, and what's shipped. + + + Live board with RFCs and epics across Creative, Media Buy, Signals, Governance, and more. + + +--- + +## How the Roadmap Works + +The board has four columns representing the lifecycle of a roadmap item: + +| Status | Meaning | +|---|---| +| **Exploring** | Under discussion, community input welcome | +| **Accepted** | Committed and scoped, not yet started | +| **In Progress** | Active work | +| **Shipped** | Released and available | + +Each item has two fields: + +- **Protocol** — which area of the protocol it affects (Creative, Media Buy, Signals, Brand Protocol, Governance, SI, TMP, Platform, Website, Addie, Certification) +- **Kind** — whether it's an **RFC** (protocol change needing community input) or an **Epic** (major deliverable spanning multiple PRs) + +--- + +## What Belongs on the Roadmap + +Not every issue or PR belongs here. Roadmap items are protocol-level changes, new capabilities, and strategic initiatives that affect adopters. An issue qualifies if it meets at least two of: + +1. **Protocol surface** — changes what agents or platforms interact with +2. **Audience impact** — would influence a prospective member's or builder's decision +3. **Multi-issue scope** — spans more than one PR + +Bug fixes, minor improvements, and internal tooling stay in the [issue tracker](https://github.com/adcontextprotocol/adcp/issues). + +--- + +## Adding Items to the Roadmap + +Any issue labeled `rfc` or `epic` is automatically added to the board. To propose a roadmap item: + +1. **Open a GitHub issue** describing the proposal +2. **Add the `rfc` or `epic` label** (maintainers can also do this during triage) +3. The issue appears in the **Exploring** column +4. A maintainer sets the **Protocol** and **Kind** fields on the board + +--- + +## Triage + +Each protocol area has a triage owner responsible for reviewing new issues weekly (~15 minutes) and deciding what gets the `rfc` or `epic` label. The triage owner also reviews the board monthly to ensure items reflect reality. + +| Protocol Area | Triage Owner | +|---|---| +| Creative | _TBD_ | +| Media Buy | _TBD_ | +| Signals | _TBD_ | +| Brand Protocol | _TBD_ | +| Governance | _TBD_ | +| SI | _TBD_ | +| TMP | _TBD_ | +| Platform | _TBD_ | +| Website | _TBD_ | +| Addie | _TBD_ | +| Certification | _TBD_ | + +Triage owners rotate quarterly. To volunteer, reach out in the relevant working group channel on Slack. + +--- + +## Version Milestones + +Named milestones group roadmap items that will ship together in a future major version. Each milestone lists accepted RFCs — exploratory items (community input open) remain on the main board until maintainers decide to land them. + +### v4.0 — target early 2027 + +v4.0 is the next **breaking-changes accumulation window**, targeted for early 2027 under the [release cadence policy](/dist/docs/3.0.13/reference/versioning#release-cadence). Breaking changes are gathered here so the ecosystem can plan a single migration window rather than chase per-minor deprecations. Items listed below are committed floor requirements for v4.0; additional items will be added here as RFCs are accepted. + +| Area | Item | +|---|---| +| Security (floor) | [Mandatory request signing for spend-committing operations (RFC 9421)](https://github.com/adcontextprotocol/adcp/issues/2307) — the 3.0 optional profile in `security.mdx` becomes required. Agents MUST sign spend-committing operations; sellers MUST verify. | + +This milestone is intentionally not a "security release." It is the version where accumulated breaking changes across protocol surfaces land together. Request signing is the current floor requirement; other accepted RFCs carrying breaking changes will be added here as they progress through the `rfc` label lifecycle on the main board. + +--- + +## Release History + +For detailed release notes and version history, see: + +- **[Release Notes](./release-notes)** — per-version feature summaries +- **[CHANGELOG.md](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md)** — technical changelog +- **[GitHub Releases](https://github.com/adcontextprotocol/adcp/releases)** — release archive +- **[Versioning & Governance](./versioning)** — versioning model and release cadence + +--- + +## Get Involved + +AdCP is developed in the open with active working groups on Slack. + +- **[Join the AgenticAds Slack](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg)** +- **[GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions)** +- **[Working Groups](../community/working-group)** diff --git a/dist/docs/3.0.13/reference/schema-extensions.mdx b/dist/docs/3.0.13/reference/schema-extensions.mdx new file mode 100644 index 0000000000..2783bebfb7 --- /dev/null +++ b/dist/docs/3.0.13/reference/schema-extensions.mdx @@ -0,0 +1,135 @@ +--- +title: Schema Extensions +sidebarTitle: Schema Extensions +description: "Reference for AdCP-specific `x-` prefixed schema annotations." +"og:title": "AdCP — Schema Extensions" +--- + +AdCP schemas carry several `x-` prefixed annotation keywords that supplement the JSON Schema vocabulary. JSON Schema validators ignore unknown `x-` keywords per [draft-07 §6](https://json-schema.org/draft-07/schema), so adding these keys is wire-compatible with any conforming validator. + +This page is the canonical reference for those annotations. Codegen consumers (TypeScript / Python / Go type generators), the storyboard runner, and the AdCP SDK family read these annotations programmatically; verifiers MAY read them but the normative behavior they describe is also documented in the relevant section of [security.mdx](/dist/docs/3.0.13/building/by-layer/L1/security) or the field's own description. + +## `x-status` + +Marks a schema or property as **experimental** — part of the core protocol but not yet frozen. Sellers implementing experimental surfaces declare the feature id in `experimental_features` on `get_adcp_capabilities`. See [Experimental Status](/dist/docs/3.0.13/reference/experimental-status) for the full graduation policy. + +```jsonc +"trusted_match": { + "type": "object", + "x-status": "experimental", + "description": "Trusted Match Protocol support..." +} +``` + +Allowed values: `"experimental"`. The keyword is omitted on stable surfaces. + +## `x-adcp-validation` + +Lifts structured normative constraints out of prose descriptions into a machine-readable shape. Storyboard runners and SDK validators consume the structured rules; codegen consumers can ignore the annotation and read the human description. + +The keyword is most useful for fields whose description currently carries a "MUST be present when..." or "MUST equal the host of..." clause that the storyboard runner cannot enforce by parsing English. + +### Shape + +```jsonc +"some_field": { + "type": "string", + "format": "uri", + "description": "Brief one or two sentences for codegen JSDoc; full constraints live in x-adcp-validation and the linked spec.", + "x-adcp-validation": { + "trust_root": true, + "required_when": { "any_of": [ ... ] }, + "schema_required_when": { ... }, + "verifier_constraints": { ... }, + "distinct_from": "other.field.path", + "spec": "docs/.../section.mdx#anchor" + } +} +``` + +### Sub-keys + +| Key | Type | Purpose | +|---|---|---| +| `trust_root` | boolean | Field is load-bearing for signature verification; verifiers MUST treat as authoritative. | +| `required_when` | object wrapping `any_of` / `all_of` | Storyboard-enforced required-when rules (3.x). The object has exactly one of `any_of` (OR-joined) or `all_of` (AND-joined), each containing an array of leaf conditions. Each leaf is one of: `{ "field": "...", "non_empty": true }`, `{ "field": "...", "equals": }`, `{ "field": "...", "any_subfield_present": true }`. The wrapping object mirrors JSON Schema's `anyOf`/`allOf` precedent so tooling readers can reuse familiar boolean-combinator semantics. Bare arrays are NOT accepted — always wrap. | +| `schema_required_when` | condition | When the rule promotes from storyboard-enforced to schema-required. Typically keyed on `adcp.supported_versions` matching a version pattern via `any_item_matches_pattern` (e.g., `"^4\\."` for the 4.0 cutover and any 4.x patch). | +| `forbidden_when` | object wrapping `any_of` / `all_of` | Inverse of `required_when`. The field MUST be absent (or `false`/empty, depending on type) when the wrapped condition holds. Same leaf shape as `required_when`. Use for fields whose presence is mutually exclusive with another posture. | +| `disjoint_with` | string (dotted path) or array of dotted paths | Item-level mutual exclusion: no value in this field's array MAY appear in any of the named arrays. Storyboard runners assert set-disjointness on each. Example: `request_signing.warn_for` carries `disjoint_with: "request_signing.required_for"` because an operation can be in one or the other, never both. | +| `subset_of` | string (dotted path) | Item-level subset constraint: every value in this field's array MUST also appear in the named array. Example: `request_signing.required_for` carries `subset_of: "request_signing.supported_for"` — an operation can't be required without being supported. | +| `verifier_constraints` | object | Free-form key-value map of verifier-side rules that don't fit the structured sub-keys above. Keys are normative (e.g., `agent_url_match: "byte_equal"`); the storyboard runner enforces these against test vectors. Prefer the structured sub-keys (`required_when`, `forbidden_when`, `disjoint_with`, `subset_of`) when they fit; reach for `verifier_constraints` only for one-off rules that don't generalize. | +| `distinct_from` | string (dotted path) | Names another field that has a similar shape but different semantics, to defuse name confusion (e.g., `identity.brand_json_url` distinct from `sponsored_intelligence.brand_url`). Verifiers MUST NOT substitute one for the other. | +| `spec` | string (relative path with anchor) | Pointer to the normative section in the docs that defines the field's full semantics. Always required when other sub-keys are present. | + +### Conformance + +- Validators MUST ignore unknown sub-keys for forward-compatibility (the schema may add new entries in a minor release). +- The storyboard runner consumes `required_when`, `schema_required_when`, and `verifier_constraints` to generate test cases per release; runners that don't yet recognize a sub-key MUST skip it and emit an "unrecognized validation rule" warning. +- Codegen consumers (TypeScript / Python / Go type generators) MAY surface `x-adcp-validation.spec` as a `@see` JSDoc link but otherwise treat the annotation as opaque. + +### Current usage + +Representative usage: + +| Field | Sub-keys used | Rule | +|---|---|---| +| `identity.brand_json_url` | `trust_root`, `required_when`, `schema_required_when`, `verifier_constraints`, `distinct_from` | Trust-root pointer for signing-key discovery; required-when tied to signing posture; schema-required in 4.0; distinct from `sponsored_intelligence.brand_url`. See [security.mdx §Discovering an agent's signing keys](/dist/docs/3.0.13/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url). | +| `identity.key_origins` | `verifier_constraints` (`purpose_anchoring`) | Every purpose listed MUST have a corresponding signing posture declared elsewhere on the response. Cross-field rule. See [security.mdx §Origin separation](/dist/docs/3.0.13/building/by-layer/L1/security#origin-separation). | +| `request_signing.required_for` | `subset_of` | Every operation listed MUST also appear in `supported_for` — an operation can't be required without being supported. | +| `request_signing.warn_for` | `disjoint_with`, `subset_of` | An operation MUST NOT appear in both `warn_for` and `required_for`. Every operation listed MUST also appear in `supported_for`. | +| `webhook_signing.supported` | `verifier_constraints` (`must_equal_when`) | When the seller advertises mutating-webhook emission (`media_buy.reporting_delivery_methods` includes `webhook` OR `media_buy.content_standards.supports_webhook_delivery: true`), `supported` MUST be `true`. Closes a downgrade vector. | +| `wholesale_feed_webhooks.event_types` | `verifier_constraints` (`wholesale_feed_webhook_capability_consistency`) | `product.*` event types require wholesale `get_products`; `signal.*` event types require wholesale `get_signals`; `wholesale_feed.bulk_change` requires at least one declared wholesale repair path and must name only a repairable feed family. | +| `get_products.wholesale_feed_version` / `get_signals.wholesale_feed_version` | `verifier_constraints` (`required_for_wholesale_request`) | Version tokens are required on wholesale read responses, but the shared response schemas cannot infer the request's `buying_mode` / `discovery_mode` from the response body alone. | + +Already enforced natively by JSON Schema and excluded from migration: +- **`adcp.idempotency`** — the discriminated `oneOf` already requires `replay_ttl_seconds` in the supported branch and forbids it in the unsupported branch. +- **`webhook_signing.algorithms`** — the `enum: ["ed25519", "ecdsa-p256-sha256"]` on each item already enforces the allowlist. + +Migration history tracked at [adcontextprotocol/adcp#3827](https://github.com/adcontextprotocol/adcp/issues/3827). + +## `x-adcp-hoist` + +Build-time directive that marks a source schema as a canonically shared type. The schema bundler hoists every inline occurrence into a single root `$defs` entry and replaces inline copies with `$ref` pointers; the directive itself is stripped from bundled output. Wire-irrelevant — validators MUST ignore it (draft-07 §6 unknown-keyword semantics) and conforming consumers SHOULD NOT observe it on bundled artifacts. + +```jsonc +{ + "$id": "/schemas/core/price-block.json", + "title": "Price Block", + "x-adcp-hoist": true, + "type": "object", + "properties": { + "cpm": { "type": "number" }, + "currency": { "type": "string" } + } +} +``` + +### Why opt-in for complex objects + +Pure enums hoist automatically (see [`hoistDuplicateInlineEnums`](https://github.com/adcontextprotocol/adcp/pull/3170)) because merging two structurally-identical enums is semantics-preserving. Complex objects are different — structural identity ≠ semantic identity. `BriefAsset` (proposed creative spec) and `VASTAsset` (delivered video creative) currently share fields but represent different lifecycle concepts; auto-merging them would create cross-tool coupling the source schemas don't express, and would be hard to unwind once SDKs codegen against the merged type. `x-adcp-hoist` makes the share-or-split decision deliberate per schema. + +### Bundler behavior + +- **Hoists at any occurrence count (≥1).** The directive declares intent — "this is a canonical named type" — so adding a second reference later never changes the codegen surface. +- **`title` is required.** Missing or empty title → build-time error. The directive is meant to be deliberate. +- **Same title + different shape is a build-time error.** Two marked schemas authored with the same `title` but distinct fields would otherwise silently suffix one to `Foo2`, defeating the directive's "canonical name" guarantee. +- **Collision with a pre-existing `$defs` key is suffixed** (`PriceBlock2`), matching the convention used by the pure-enum hoist. +- **Stripped from bundled output** — both from the canonical `$defs` entry and from any stray marker that was authored inside a pre-existing `$defs` block. + +### SDK / codegen impact + +Adding `x-adcp-hoist` to a previously-inlined source schema is wire-compatible (the bundled schema still validates the same payloads) but is a codegen-shape change: TypeScript / Python / Go type generators that previously emitted an anonymous inline type (often `Foo1`, `Foo2`, …) will now emit a single named type. SDK adopters maintain rename aliases per their own deprecation policy — see [adcp-client#942](https://github.com/adcontextprotocol/adcp-client/issues/942) for the client-side rename/alias tracking. + +### Conformance + +- Validators MUST ignore `x-adcp-hoist` per draft-07 §6 (unknown keywords are tolerated). The directive has no wire semantics. +- Source-tree consumers (third parties that dereference `static/schemas/source/...` directly rather than the bundled artifacts) MUST treat `x-adcp-hoist: true` as a no-op annotation. The schema's content is the contract. +- Bundlers other than `scripts/build-schemas.cjs` MAY honor the directive or ignore it; a bundler that ignores it produces a wire-compatible bundle with un-deduped inline copies. + +### History + +- Introduced in [#4557](https://github.com/adcontextprotocol/adcp/issues/4557). Successor to [#3145](https://github.com/adcontextprotocol/adcp/issues/3145) phase 2. + +## Future extensions + +New `x-adcp-*` keywords are added in minor releases. Consumers MUST tolerate unknown `x-` keywords without erroring. The convention reserves the `x-adcp-` namespace; vendor-specific or deployment-specific annotations SHOULD use a vendor-specific prefix (e.g., `x-yourorg-`) to avoid collision. diff --git a/dist/docs/3.0.13/reference/specification-lifecycle.mdx b/dist/docs/3.0.13/reference/specification-lifecycle.mdx new file mode 100644 index 0000000000..6561cbd11f --- /dev/null +++ b/dist/docs/3.0.13/reference/specification-lifecycle.mdx @@ -0,0 +1,124 @@ +--- +title: Specification lifecycle +sidebarTitle: Specification lifecycle +description: "How AdCP specification sections move from Draft to Final, who decides each transition, and what stability contract each stage carries for implementers." +"og:title": "AdCP — Specification lifecycle" +--- + +Every section of the AdCP specification is at one of five stages. The stage tells an implementer how much stability to expect: a Draft section carries no contract; a Final section is protected by the full [3.x stability guarantees](/dist/docs/3.0.13/reference/versioning#3x-stability-guarantees). + +--- + +## Stages at a glance + +| Stage | Vocabulary match | Stability contract | Safe to build production systems against? | +|---|---|---|---| +| **Draft** | Pre-schema | None | No | +| **Proposed** | Experimental (`x-status: experimental`) | [Experimental contract](/dist/docs/3.0.13/reference/experimental-status#contract-for-experimental-surfaces) | With caution — may break with 6-week notice | +| **Final** | Stable (no `x-status` marker) | Full [3.x guarantees](/dist/docs/3.0.13/reference/versioning#3x-stability-guarantees) | Yes | +| **Deprecated** | Stable, announced for removal | Same as Final until the removal release | Yes — but plan migration | +| **Sunset** | Removed | None | No — feature is gone | + +The vocabulary match column is key: the spec stages and the schema annotations are two views of the same state. If a JSON Schema in `static/schemas/source/` has `"x-status": "experimental"`, the section it belongs to is at the **Proposed** stage. If it has no `x-status` marker and the schema has shipped in a GA release, it is at the **Final** stage. + +--- + +## Stage definitions + +### Draft + +A section is in Draft when the working group has opened a scoped proposal on the GitHub milestone — a `spec / protocol` labeled issue with a defined scope — but no schema has been published in `static/schemas/source/` yet. Draft sections: + +- Carry **no stability contract of any kind**. Builders who proceed against a Draft section do so at their own risk and SHOULD track the relevant GitHub milestone for changes. +- Are not declared in `get_adcp_capabilities`. A seller cannot claim conformance with a Draft section. +- May be abandoned before reaching Proposed. + +**Entry**: A scoped spec issue is accepted onto the working group's active milestone. The domain working group lead confirms the scope is in window. + +**Decision authority**: Domain working group lead. + +**When the RFC process applies**: A material spec change — new task, schema change affecting existing fields, or change to normative prose — requires an approved [RFC](https://github.com/adcontextprotocol/adcp/issues/2437) before entering Draft. The RFC documents the motivation, alternatives considered, compatibility impact, and reviewer checklist. Lightweight changes (new optional fields with no behavioral consequence) may enter Draft via a PR with working group review in lieu of a formal RFC. + +--- + +### Proposed + +A section is Proposed when its schema exists in `static/schemas/source/` with `"x-status": "experimental"` at the schema root or on the specific properties, **and** the seller implementing it declares the feature id in `experimental_features` on `get_adcp_capabilities`. + +The full Proposed-stage contract — including what may change with 6-week notice, what does not change (auth, transport, error envelope), and the seller declaration requirements — is specified in [Experimental Status](/dist/docs/3.0.13/reference/experimental-status). That page governs; this page maps stage vocabulary to it. + +**Entry**: The schema is published in a 3.x release with `x-status: experimental`. The architecture committee review that accompanies the release confirms experimental status. + +**Decision authority**: Architecture committee, at release time. + +--- + +### Final + +A section is Final when its schema is stable — `x-status: experimental` has been removed via a deliberate graduation PR reviewed by the architecture committee. + +The four graduation criteria are specified in [Experimental Status — Graduation to stable](/dist/docs/3.0.13/reference/experimental-status#graduation-to-stable) — including the production signal requirement, cross-party validation bar (a second implementation running ≥45 days with at least one in production, or one implementation plus buyer integration), schema stability window, and the deliberate promotion PR. That page governs; this page maps stage vocabulary to it. The architecture committee reviews graduation PRs at each 3.x release. + +Once Final, a section is protected by the full [3.x stability guarantees](/dist/docs/3.0.13/reference/versioning#3x-stability-guarantees): fields are never removed, enums are additive only, task names are never removed or renamed within the major version. **A Final section in 3.x remains Final — under the same guarantees — throughout the 4.0 development cycle.** A section under active 4.0 scoping is not retroactively downgraded; the 3.x contract holds until 3.x support ends. See [Support window for previous major](/dist/docs/3.0.13/reference/versioning#support-window-for-previous-major). + +**Entry**: All four graduation criteria from experimental-status.mdx are met. Architecture committee approves and merges the graduation PR in a scheduled 3.x release. + +**Decision authority**: Architecture committee. + +--- + +### Deprecated + +A section is Deprecated when a formal deprecation notice has been published in the release notes and changelog, and the 6-month countdown to removal has started. The section remains fully functional and under the 3.x stability guarantees during the deprecation window. + +The deprecation policy — 6-month minimum notice, feature persists through at least one full release cycle after deprecation, never removed within the same major version — is specified in [Versioning — Deprecation policy](/dist/docs/3.0.13/reference/versioning#deprecation-policy). + +**Entry**: Deprecation notice lands in the release notes and changelog for a 3.x release. The replacement (if any) ships in the same release. The architecture committee confirms the removal target (always a major-version boundary). + +**Decision authority**: Architecture committee, with working group review. + +**Announcement**: Release notes entry with `deprecated` label + migration note, changelog entry, inline `@deprecated` annotation in the schema. + +--- + +### Sunset + +A section is Sunset (removed) once the major-version boundary it was targeted for has shipped. By policy, a Deprecated section is never removed within the same major version — the earliest removal date is the GA of the next major. The v2 sunset timeline is documented on the [v2 sunset page](/dist/docs/3.0.13/reference/v2-sunset). + +**Entry**: The successor major ships GA. The previously-deprecated fields, tasks, or schema properties are omitted from the new major's schemas. + +**Decision authority**: Implicit — the major-version release process carries the removal. + +--- + +## Transition diagram + +``` +Draft ──→ Proposed ──→ Final ──→ Deprecated ──→ Sunset + └──→ (abandoned) (stable) (6-mo notice) (removed at + major boundary) +``` + +Draft may also be abandoned without reaching Proposed. A Final section may be Deprecated directly (no intermediate stage). There is no path from Sunset back to any prior stage — removed features are not restored; they are re-proposed from scratch if needed. + +Stage transitions (Draft → Proposed → Final, Final → Deprecated, etc.) are gated on an approved RFC — the RFC outcome (accepted / accepted-with-changes) is the trigger that authorises a contributor to open the spec PR that carries the transition. See [RFC process (issue #2437)](https://github.com/adcontextprotocol/adcp/issues/2437). + +--- + +## How to check the current stage of a spec section + +1. **Schema marker**: Check the relevant schema in `static/schemas/source/`. `"x-status": "experimental"` → Proposed. No marker on a shipped schema → Final. Not in `static/schemas/source/` but referenced in an open GitHub milestone issue → Draft. +2. **Experimental surfaces list**: The [Experimental Status](/dist/docs/3.0.13/reference/experimental-status) page lists every currently-Proposed surface with its feature id and current status. +3. **GitHub milestones**: Open issues on the active milestone represent Draft and Proposed work. Shipped items without `x-status` in the merged schema are Final. +4. **Release notes**: Deprecated sections are called out with a `deprecated` label and removal target in every release's notes. + +--- + +## Working group and governance + +The procedures that govern who sits on the working group, voting thresholds, quorum, and escalation paths: + +- **RFC process** ([#2437](https://github.com/adcontextprotocol/adcp/issues/2437)): proposal template, decision-record format, lifecycle from RFC to spec change +- **Working group charter** ([#2438](https://github.com/adcontextprotocol/adcp/issues/2438)): quorum, voting thresholds, roster, recusal policy — under active development + +The architecture committee is the defined decision authority for Final-stage graduation and Deprecated-stage entry. It is referenced in [Experimental Status](/dist/docs/3.0.13/reference/experimental-status) and acts as the working group's ratifying body for stage transitions that carry a stability contract. diff --git a/dist/docs/3.0.13/reference/test-vectors/index.mdx b/dist/docs/3.0.13/reference/test-vectors/index.mdx new file mode 100644 index 0000000000..7674875605 --- /dev/null +++ b/dist/docs/3.0.13/reference/test-vectors/index.mdx @@ -0,0 +1,56 @@ +--- +title: Reference Test Vectors +sidebarTitle: Reference test vectors +description: "Machine-readable fixtures SDKs and implementations diff against to confirm wire-format agreement. Versioned alongside the spec; frozen at each release." +"og:title": "AdCP — Reference Test Vectors" +--- + +**Status**: Request for Comments +**Last Updated**: April 20, 2026 + +## What these are + +Reference test vectors are machine-readable JSON fixtures pinned to specific wire-format rules in the spec. An SDK whose output matches a vector's `expected_*` field byte-for-byte has agreed with the reference on that rule's wire format — not a conformance claim (only [storyboards](/dist/docs/3.0.13/building/verification/conformance) decide that), but a necessary precondition for interop. An SDK that diverges has an interop bug, even if its own tests pass. + +Vectors complement [storyboards](/dist/docs/3.0.13/building/verification/conformance). Storyboards exercise an agent end-to-end to produce a pass/fail verdict; vectors exercise a library in isolation against frozen inputs. A vector tells a signer "this 9421 request MUST produce this signature base"; a storyboard tells an agent "when the buyer sends this request, you MUST respond with a result shaped like this." Most conformant stacks need both — vectors catch canonicalization drift inside a library; storyboards catch behavior drift at the wire. + +Vectors are not the conformance specification — the [storyboards](/dist/docs/3.0.13/building/verification/conformance) are. Vectors are reference inputs the storyboards and SDK unit tests consume. + +## Versioning + +Vector sets published under the compliance tree — `request-signing`, `webhook-signing`, `plan-hash` — are versioned alongside the spec. The copy served at `/compliance/{version}/test-vectors/{set}/` is frozen at the GA release of that version; fixes that change a vector's bytes ship in the next AdCP minor release. `/compliance/latest/test-vectors/{set}/` tracks the most recent GA and moves under you between releases. + +The transport and response-extraction vectors served at `/test-vectors/{name}.json` are currently unversioned: each file is overwritten in place when it changes. SDKs that consume these fixtures SHOULD vendor a commit-pinned copy, for example fetching from `https://raw.githubusercontent.com/adcontextprotocol/adcp//static/test-vectors/.json` and recording `` in their lockfile, until these files are rolled into the versioned compliance tree. + +SDKs SHOULD fetch versioned paths where available and record the version under test. For pinned versions, the CDN copy at `/compliance/{version}/...` is the source of truth; `/compliance/latest/...` is a convenience alias, not a stable pin. + +## Published sets + +| Set | What it pins | Source | CDN | +|---|---|---|---| +| [`request-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/request-signing) | RFC 9421 request-signing profile: canonical signature base, covered components, signature params, tag namespace, alg allowlist, `adcp_use` discriminator, replay dedup, revocation, content-digest semantics, and URL canonicalization | `static/compliance/source/test-vectors/request-signing/` | `/compliance/latest/test-vectors/request-signing/` | +| [`webhook-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/webhook-signing) | RFC 9421 webhook-signing profile: required covered components (content-digest mandatory — no `forbidden` opt-out), `adcp/webhook-signing/v1` tag, `adcp_use: "webhook-signing"` discriminator, `webhook_signature_*` error taxonomy; shares `@target-uri` canonicalization with `request-signing` | `static/compliance/source/test-vectors/webhook-signing/` | `/compliance/latest/test-vectors/webhook-signing/` | +| [`plan-hash`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/plan-hash) | JCS canonicalization of the `plan_hash` preimage: required-only baseline, full-optional, bookkeeping-stripped, omitted-vs-explicit-null, array-order sensitivity, `ext.trace_id` distinctness, Unicode non-normalization (RFC 8785 §3.2.5) | `static/compliance/source/test-vectors/plan-hash/` | `/compliance/latest/test-vectors/plan-hash/` | +| [`transport-error-mapping`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/transport-error-mapping.json) | Transport-layer error envelope shapes: the JSON-RPC (`error.code` / `data`) and A2A (task `status.message`) carriers for each documented AdCP transport error | `static/test-vectors/transport-error-mapping.json` | [`/test-vectors/transport-error-mapping.json`](https://adcontextprotocol.org/test-vectors/transport-error-mapping.json) | +| [`mcp-response-extraction`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/mcp-response-extraction.json) | Client extraction of the AdCP payload from MCP `tools/call` envelopes | `static/test-vectors/mcp-response-extraction.json` | [`/test-vectors/mcp-response-extraction.json`](https://adcontextprotocol.org/test-vectors/mcp-response-extraction.json) | +| [`a2a-response-extraction`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/a2a-response-extraction.json) | Client extraction of the AdCP payload from A2A task statuses and artifacts | `static/test-vectors/a2a-response-extraction.json` | [`/test-vectors/a2a-response-extraction.json`](https://adcontextprotocol.org/test-vectors/a2a-response-extraction.json) | +| [`webhook-payload-extraction`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/webhook-payload-extraction.json) | Receiver-side format detection and payload extraction for inbound AdCP webhooks | `static/test-vectors/webhook-payload-extraction.json` | [`/test-vectors/webhook-payload-extraction.json`](https://adcontextprotocol.org/test-vectors/webhook-payload-extraction.json) | +| [`webhook-hmac-sha256`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/webhook-hmac-sha256.json) *(legacy)* | HMAC-SHA-256 signature computation and byte-equality invariants for the legacy HMAC webhook profile. Deprecated in 3.x, removed in 4.0 per [Webhook callbacks](/dist/docs/3.0.13/building/by-layer/L3/webhooks#legacy-hmac-sha256-fallback-deprecated); new integrations use `webhook-signing` | `static/test-vectors/webhook-hmac-sha256.json` | [`/test-vectors/webhook-hmac-sha256.json`](https://adcontextprotocol.org/test-vectors/webhook-hmac-sha256.json) | + +**Start here**: every set's `README.md` in the source column documents file layout, key material, preconditions (e.g., runner state required for replay vectors), and how to wire the set into an SDK test loop. The README in the source tree is authoritative; the index on this page is a catalog, not an integration guide. + +Directory CDN paths (the three compliance-tree rows) are base paths for programmatic use — the CDN serves individual files, not directory listings. Browse the tree via the source column. + +## Test keys are public + +Every signing vector set ships private key material in `keys.json` so libraries can exercise signer and verifier roles against identical inputs. These keys are **valid only for grading against this suite**. + +Any production verifier that trusts a `kid` declared in one of the published `keys.json` files is exploitable — the private key is on the public CDN and anyone can forge signatures under that kid. At time of writing this includes `test-ed25519-2026`, `test-es256-2026`, `test-gov-2026`, `test-revoked-2026` (request-signing) and `test-ed25519-webhook-2026`, `test-es256-webhook-2026`, `test-wrong-purpose-2026`, `test-revoked-webhook-2026` (webhook-signing). Treat every `kid` that appears in any suite `keys.json` as untrusted outside grading, present or future. + +Production signers mint their own keypairs and publish under their own `jwks_uri`; production verifiers MUST NOT register any test `kid` in a trust store exposed to live traffic. + +## Planned coverage + +The sets above exercise transport, signing, and canonicalization rules that cross every surface. Task-level vectors — per-task request/response pairs covering happy path, each documented error code, lifecycle transitions, and idempotency replay/conflict/expired cases across the `media-buy/`, `creative/`, `signals/`, `curation/`, `brand-protocol/`, `trusted-match/`, and `sponsored-intelligence/` task directories — are scoped in [issue #2383](https://github.com/adcontextprotocol/adcp/issues/2383) against the **3.1** milestone and will land incrementally under this path between 3.0 GA and 3.1 release. (These are task directory names, not `supported_protocols` values; see the [Conformance Specification](/dist/docs/3.0.13/building/verification/conformance#protocol-conformance) for the protocol taxonomy.) + +Until task-level vectors ship, the authoritative source for task shapes is the pair of [conformance storyboards](https://adcontextprotocol.org/compliance/latest/) (wire behavior) and [JSON Schemas](https://github.com/adcontextprotocol/adcp/tree/main/static/schemas/source) (request/response shapes). Neither yields ready-made fixtures — implementers derive expected shapes from the schema and confirm wire behavior by running the storyboards against their agent. diff --git a/dist/docs/3.0.13/reference/url-canonicalization.mdx b/dist/docs/3.0.13/reference/url-canonicalization.mdx new file mode 100644 index 0000000000..56ae3de44a --- /dev/null +++ b/dist/docs/3.0.13/reference/url-canonicalization.mdx @@ -0,0 +1,66 @@ +--- +title: URL Canonicalization +description: "The canonicalization rules AdCP uses everywhere two URLs are compared as identifiers — request signing, authorization matching, and registry lookups." +"og:title": "AdCP — URL Canonicalization" +--- + +AdCP compares URLs as identifiers in several places: the request-signing profile's `@target-uri`, `authorized_agents[].url` entries in `adagents.json`, `seller_agent.agent_url` on TMP `AvailablePackage`, `agent_url` in `format-id` and `ProviderEntry`, and any other registry where a URL is a primary key. A single canonicalization algorithm governs all of these so two byte-different-but-semantically-equal URLs compare equal regardless of which surface is doing the lookup. This page is the authoritative home of that algorithm; the [request-signing profile](/dist/docs/3.0.13/building/by-layer/L1/security#signed-requests-transport-layer) cites it and adds transport-specific extensions. + +## Algorithm + +The canonicalization applies RFC 3986 §6.2.2 (syntax-based normalization) and §6.2.3 (scheme-based normalization), in this order. Implementations MUST apply every step and compare the result byte-for-byte. + +1. **Lowercase the scheme** (`HTTPS` → `https`). The scheme itself is preserved — `http` and `https` canonicalize to different forms and MUST NOT match in an identifier comparison. + +2. **Lowercase the host.** For IDN labels, convert to Punycode A-labels (ACE form) using **UTS-46 Nontransitional processing with `CheckHyphens=true`, `CheckBidi=true`, `UseSTD3ASCIIRules=true`, `Transitional_Processing=false`** (`bücher.example` → `xn--bcher-kva.example`). The processing-mode pin matters: ASCII-lowercasing non-ASCII input before ToASCII produces a different A-label than UTS-46-correct processing, and TypeScript (`url.domainToASCII`), Go (`golang.org/x/net/idna`), and Python (the `idna` package — *not* `str.encode('idna')`, which is IDNA2003) legitimately diverge on mode defaults. A host containing raw non-ASCII bytes that has not been ToASCII-normalized by the producer MUST be rejected by the comparer — receivers do not silently re-normalize. For IPv6 literals, preserve the `[` and `]` brackets and lowercase the hex digits inside them (`[2001:DB8::1]` → `[2001:db8::1]`). **IPv6 zone identifiers (RFC 6874) MUST be rejected** — zone-ids are node-local and have no meaning outside the producing host. Implementations MUST reject any URL containing `%25` inside `[...]`. + +3. **Strip userinfo.** `user:pass@host` → `host`. The following authority shapes are malformed and MUST be rejected — producers MUST NOT emit them, comparers MUST reject them: + - Userinfo but no host: `https://user@/p` + - No host at all: `https:///p`, `https://:443/p` + - Bracketed host missing a closing bracket: `https://[::1/p` + - Bare IPv6 address outside brackets: `https://fe80::1/p` + +4. **Strip default ports.** `:443` for https, `:80` for http. Preserve all other ports (`:8443`). + +5. **Apply `remove_dot_segments` (RFC 3986 §5.2.4) to the path, but preserve consecutive slashes byte-for-byte.** `/a//b` MUST stay `/a//b` — RFC 3986 does not mandate collapsing them, and preserving closes a path-confusion attack surface: if one side collapses `/admin//foo` → `/admin/foo` and the other dispatches `/admin//foo` to a different (potentially less-guarded) handler, an attacker can sign or authorize one URL and execute another. Servers deploying URL-based authorization MUST disable slash-folding on affected routes (`nginx: merge_slashes off;`, Express: do not pre-normalize, Go 1.22+ `http.ServeMux`: use an explicit `http.Handler` that preserves the incoming path). If the path is empty AND an authority is present, substitute `/` (RFC 3986 §6.2.3; `https://host?x=1` → `https://host/?x=1`). + +6. **Normalize percent-encoding.** Uppercase hex digits (`%2f` → `%2F`). Decode percent-encoded unreserved characters (`ALPHA / DIGIT / "-" / "." / "_" / "~"` per RFC 3986 §2.3, so `%7E` → `~`, `%2Dfoo` → `-foo`, `%41` → `A`). Leave reserved characters percent-encoded (`%3A` stays `%3A`, `%2F` stays `%2F`). Percent-encoding normalization applies to path and query; zone identifiers are rejected at step 2 so they never reach this step. + +7. **Preserve the query string byte-for-byte.** MUST NOT reorder parameters, MUST NOT re-encode, MUST NOT interpret `+` as space. A trailing `?` with empty query is preserved (`https://host/p?` canonicalizes to `https://host/p?`, distinct from `https://host/p`). A URL with no `?` stays with no `?`. Two URLs that differ only by query-parameter order are different canonical forms, not equivalent. + +8. **Strip the fragment.** Fragments never participate in identifier comparison and are not sent on the wire per RFC 9421 §2.2.2. + +After all eight steps, comparison is byte-for-byte. Implementations MUST NOT apply additional transformations before comparison. + +## Where it applies + +| Surface | Comparison | Reference | +|---|---|---| +| Request signing | `@target-uri` canonical output signed and verified | [Signed Requests (Transport Layer)](/dist/docs/3.0.13/building/by-layer/L1/security#signed-requests-transport-layer) | +| TMP seller authorization | `seller_agent.agent_url` vs `authorized_agents[].url` | [TMP Sync-Time Validation](/dist/docs/3.0.13/trusted-match/specification#sync-time-validation) | +| TMP provider resolution | `ProviderEntry.agent_url` vs router's registered provider endpoint | [TMP Product Integration](/dist/docs/3.0.13/trusted-match/specification#product-integration) | +| `adagents.json` lookups | Any caller asking "is this agent authorized for this property?" | [adagents.json schema](https://adcontextprotocol.org/schemas/3.0.13/adagents.json) | +| `format-id` resolution | `format-id.agent_url` against the URL an agent publishes for its formats | [format-id schema](https://adcontextprotocol.org/schemas/3.0.13/core/format-id.json) | +| `adagents.json` `authoritative_location` indirection | Following the pointer; the target URL MUST canonicalize the same way | [Managed networks](/dist/docs/3.0.13/governance/property/managed-networks#security-considerations) | +| Any registry with a URL primary key | Canonical form is the key; raw input is not | — | + +## Signing profile extensions + +The [request-signing profile](/dist/docs/3.0.13/building/by-layer/L1/security#signed-requests-transport-layer) layers transport-specific rules on top of this algorithm: + +- `@authority` is derived from the canonicalized authority and compared against the HTTP/2 `:authority` pseudo-header (or the as-received HTTP/1.1 `Host` header) after the same canonicalization. Non-signing callers derive `@authority` from the URL alone. +- Malformed authorities are rejected with `request_target_uri_malformed` on the signing path; non-signing callers use their own authorization-failure code (e.g., `seller_not_authorized` for TMP). +- When both `:authority` and `Host` are present on an as-received HTTP/2 request, the signing profile requires byte-equality after canonicalization; this is a signing-specific gate because HTTP/1.1 `Host` can be rewritten in transit. + +## Conformance vectors + +The [`canonicalization.json`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/canonicalization.json) set exercises every rule above with fixed `{ input_url, expected_target_uri, expected_authority }` triples, plus malformed-authority rejection cases. Non-signing callers compare against `expected_target_uri` only — `expected_authority` is the HTTP-header-derived form used by the signing profile. SDKs implementing any of the surfaces in the table above SHOULD run this set on every commit; canonicalization divergence is silent until a production interop bug surfaces. + +## Common pitfalls + +- **ASCII-lowercasing an IDN before ToASCII.** `Bücher.example` lowercased in ASCII → `bücher.example`, but a UTS-46-correct path must process the original bytes. TypeScript `url.domainToASCII`, Go `golang.org/x/net/idna`, and Python's `idna` package (not `str.encode('idna')`, which is IDNA2003) diverge on mode defaults; pin to UTS-46 Nontransitional with the four flags above. +- **Collapsing consecutive slashes.** `/admin//foo` and `/admin/foo` are different canonical forms. A producer that collapses and a comparer that doesn't (or vice versa) opens a path-confusion attack. +- **Re-encoding the query.** Query-string normalization looks tempting but is forbidden. `?x=1&y=2` and `?y=2&x=1` are different canonical forms. +- **Trailing `?` with empty query.** `https://host/p?` and `https://host/p` are different. Preserve whichever the producer sent. Publishers registering URLs in `adagents.json` or similar registries should paste them without a trailing `?` unless they intend the empty-query form. +- **Forgetting the fragment strip.** Fragments never participate in identifier comparison. +- **Mixing `http://` and `https://`.** Scheme is preserved, not coerced. Publishers registering an `authorized_agents[].url` MUST use `https://` for anything meant to be reachable on the public internet — an `http://` entry will fail to match an `https://` caller and vice versa, and non-HTTPS URLs have no transport-integrity guarantee. diff --git a/dist/docs/3.0.13/reference/v2-sunset.mdx b/dist/docs/3.0.13/reference/v2-sunset.mdx new file mode 100644 index 0000000000..b61edfadd7 --- /dev/null +++ b/dist/docs/3.0.13/reference/v2-sunset.mdx @@ -0,0 +1,71 @@ +--- +title: v2 Sunset +sidebarTitle: v2 sunset +description: "AdCP v2 is unsupported as of 3.0 GA. Security-only patches through August 1, 2026 (UTC); full deprecation thereafter. Begin migration to 3.0 now." +"og:title": "AdCP — v2 Sunset" +--- + + +**AdCP v2 is not supported and is not safe for interoperable production on the AAO network.** v2 predates the accounts and governance protections that the AAO architecture committee considers essential for live multi-party campaigns. The last v2 release was 2.5.3 in January 2026. Begin migrating to [3.0](/dist/docs/3.0.13/reference/whats-new-in-v3) now. + + +## Timeline + +| Date (UTC) | Event | +|---|---| +| **April 2026** (3.0 GA) | v2 no longer supported by AAO. Security-only patches begin. No feature work. | +| **August 1, 2026** | v2 fully deprecated. No further patches. Reference documentation archived. | + +The v2 sunset is not tied to 4.0. v2 reaches end of life on its own schedule because 2.5 was an early, preliminary version — adoption remained small, and the architecture committee identified protections missing from v2 that cannot be backported without a major redesign. v2's shorter window is the documented exception to the [12-month previous-major support commitment](/dist/docs/3.0.13/reference/versioning#support-window-for-previous-major); future majors will not invoke this exception. + +## What "unsupported" means + +As of 3.0 GA: + +- **No feature work.** 2.5.3 is the last v2 release. No further minor or patch work in the 2.x line, other than security advisories. +- **No AAO certification.** Certification requires v3.0 or later. +- **Not verified in the AAO registry.** Verified seller and agent status requires v3.x. v2-only agents can be registered but cannot be verified. +- **Frozen schemas.** v2 schemas are frozen as of 2.5.3 and remain at their existing schema URLs. No new fields, tasks, or protocols. + +After August 1, 2026 (UTC), v2 is fully deprecated: security patches stop and reference documentation is archived. The 2.5.3 schema URLs continue to resolve so existing integrations do not break silently, but those schemas receive no further updates. + +## Why v2 is not safe for interoperable production + +v2 is missing protections that 3.0 treats as essential for live multi-party campaigns on the AAO network: + +- **No accounts protocol.** Buyers and sellers cannot negotiate account scope, operator authorization, or buyer identity resolution. +- **No governance.** No structured content standards, audience bias validation, or property list governance. +- **No campaign governance.** No signed proposals, approval workflow, or proposal lifecycle. +- **Limited optimization and measurement.** No structured optimization goals, event source health, or streaming/audio delivery metrics. + +Running v2 on the AAO network means running without these protections. Private deployments with their own governance and identity layers are the operator's call — but the AAO network treats v2 as out of scope for supported production traffic. + +## What to do + +### If you run a v2 agent + +1. Read [What's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3). +2. Work through the [v2→v3 migration guide](/dist/docs/3.0.13/reference/migration). +3. Validate with [storyboard testing](/dist/docs/3.0.13/reference/migration/v3-readiness). +4. Complete migration before August 1, 2026 (UTC). + +### If you are a buyer integrating with v2 sellers + +1. Inventory which of your sellers are v2-only. +2. Notify them of the August 1, 2026 (UTC) deprecation and share the [migration guide](/dist/docs/3.0.13/reference/migration). +3. Shift traffic to v3 sellers as they become available. + +### If you use the AAO registry + +Verified seller and agent status requires v3.x. v2-only agents can be registered but are not eligible for verification. As the registry rolls out verified-default discovery, v2-only entries will not surface by default; endpoints that return unverified entries remain available for operators who need them. + +## Dual-support during migration + +Sellers in mid-migration can temporarily support both versions using `get_adcp_capabilities` and the `major_versions` array — see [Running v2 and v3 side by side](/dist/docs/3.0.13/reference/migration#running-v2-and-v3-side-by-side). Dual-support is a migration tool, not a long-term posture. After August 1, 2026 (UTC), v3-only is the required configuration. + +## Related + +- **[What's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3)** — feature-by-feature summary of what 3.0 adds +- **[Migration guide](/dist/docs/3.0.13/reference/migration)** — breaking changes and deep-dive pages for every protocol area +- **[v3 readiness checklist](/dist/docs/3.0.13/reference/migration/v3-readiness)** — 8 minimum requirements for storyboard testing +- **[Versioning & governance](/dist/docs/3.0.13/reference/versioning)** — 3.x stability guarantees and release cadence diff --git a/dist/docs/3.0.13/reference/verifying-protocol-tarballs.mdx b/dist/docs/3.0.13/reference/verifying-protocol-tarballs.mdx new file mode 100644 index 0000000000..bafff2a555 --- /dev/null +++ b/dist/docs/3.0.13/reference/verifying-protocol-tarballs.mdx @@ -0,0 +1,124 @@ +--- +title: "Verifying protocol tarballs" +description: "Verify AdCP protocol bundle publisher identity with cosign keyless and the Sigstore transparency log." +"og:title": "AdCP — Verifying protocol tarballs" +--- + +Every AdCP release publishes a `{version}.tgz` bundle (the full schema + compliance + OpenAPI tree at that version) along with three sidecars: + +| File | Role | +|---|---| +| `{version}.tgz.sha256` | SHA-256 checksum, in-transit integrity | +| `{version}.tgz.sig` | Sigstore detached signature | +| `{version}.tgz.crt` | Fulcio-issued signing certificate | + +The SHA-256 sidecar lives on the same origin as the tarball, so it only protects against transit tampering. The `.sig` + `.crt` pair proves the bundle came from the AdCP release workflow itself and was not swapped for a malicious one even if the host were compromised. + +This page covers how to verify those signatures correctly. SDK users (`@adcp/sdk`, `adcp-client-python`, `adcp-go`) get this verification for free on every `sync-schemas` / `download.sh` run. If you're consuming the bundle directly — pinning a specific version in a CI pipeline, ingesting it from a different language, or implementing a fresh adopter — read on. + +## Trust model + +AdCP uses **Sigstore keyless signing**. There is no long-lived private key. At release time: + +1. The `release.yml` workflow on `adcontextprotocol/adcp` runs on a GitHub Actions runner. +2. The runner mints a short-lived OIDC token whose subject identifies the workflow and ref that produced the run. +3. `cosign sign-blob --yes` exchanges that OIDC token at Sigstore's Fulcio CA for a short-lived X.509 certificate, then produces a detached signature using the cert's ephemeral private key. +4. The signature, certificate, and a transparency log entry land in Sigstore's Rekor public log. +5. The release pipeline commits `.sig` and `.crt` next to the tarball and uploads them to the GitHub Release. + +Verification on the consumer side then checks **two binding properties**: + +- **Signature authenticity** — the `.sig` was produced by the private key that the `.crt` certifies. Standard Sigstore math; no AdCP-specific. +- **Identity binding** — the `.crt`'s subject names the AdCP release workflow specifically, with the issuer being GitHub Actions's OIDC provider. This is the AdCP-specific part. + +If both hold, you have proof that an AdCP release workflow run produced this exact tarball — provable end-to-end without trusting `adcontextprotocol.org` itself. + +## Recommended `cosign verify-blob` invocation + +```bash +# Download the tarball + sidecars +curl -OL https://adcontextprotocol.org/protocol/3.0.3.tgz +curl -OL https://adcontextprotocol.org/protocol/3.0.3.tgz.sha256 +curl -OL https://adcontextprotocol.org/protocol/3.0.3.tgz.sig +curl -OL https://adcontextprotocol.org/protocol/3.0.3.tgz.crt + +# Verify checksum first (cheap, catches in-transit corruption) +shasum -a 256 -c 3.0.3.tgz.sha256 + +# Verify Sigstore identity (proves publisher) +cosign verify-blob \ + --signature 3.0.3.tgz.sig \ + --certificate 3.0.3.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.0.3.tgz +``` + +Both must exit zero before extracting. `cosign verify-blob` returns non-zero if the signature was made by anything other than the AdCP release workflow, even if the SHA matches and TLS is valid. + +## The identity regex, explained + +``` +^https://github\.com/adcontextprotocol/adcp/\.github/workflows/release\.yml@refs/(heads|tags)/.*$ +``` + +Three pieces matter: + +- `https://github.com/adcontextprotocol/adcp/.github/workflows/release.yml` — the workflow file path. This is what makes the certificate AdCP-specific. A workflow in a different repo, or a different workflow file in this repo, won't match. +- `refs/(heads|tags)/.*` — the ref the workflow ran against. Branch refs are what's used today (cosign signs during the push-triggered run, so the OIDC subject is `release.yml@refs/heads/`). Tag refs are forward-compat for any future post-tag re-signing flow. +- `--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'` — the OIDC issuer must be GitHub Actions itself. Even with the right repo and workflow path, a non-GitHub-Actions issuer would fail this check. + +### Why a regex, not an exact ref + +The first version of this regex was `^...refs/heads/(main|2\.6\.x)$` — a literal allowlist of release branches. It silently rejected v3.0.1+ when those releases moved to `refs/heads/3.0.x` (the maintenance branch added when the 3.0 line was cut). Any new maintenance branch broke verification across every consumer until each SDK was patched. + +Wildcarding the branch component doesn't weaken the trust model: the upstream `release.yml` workflow's own `on.push.branches` allowlist (currently `main`, `3.0.x`, `2.6.x`) is what determines which refs can produce a signature in the first place. Mirroring that list in every consumer's regex was a maintenance liability that added no defense. + +## Cert subjects on past releases + +For reference, here's what each release's certificate subject looked like: + +| Release | Triggering ref | Cert subject (subject only, full URL prefix omitted) | +|---|---|---| +| v3.0.0 | `main` (initial 3.0 cut) | `release.yml@refs/heads/main` | +| v3.0.1 | `3.0.x` (after the line was cut) | `release.yml@refs/heads/3.0.x` | +| v3.0.2 | `3.0.x` | `release.yml@refs/heads/3.0.x` | +| v3.0.3 | `3.0.x` | `release.yml@refs/heads/3.0.x` | + +A future maintenance branch (e.g. `2.7.x`) would add `release.yml@refs/heads/2.7.x` without needing any consumer change. + +## When verification is not available + +Some releases legitimately ship without `.sig`/`.crt`: + +- **Pre-v3.0.0 (cosign signing wasn't wired in yet).** Treat as checksum-only. SDKs degrade to integrity-only verification rather than failing. +- **Out-of-band republishes.** If a tarball is regenerated outside the `release.yml` workflow (e.g. a one-off rebuild), it has no Sigstore identity. The cosign sidecars will be absent. Treat as untrusted. + +Consumers should distinguish "sidecars absent" (degrade to checksum-only) from "sidecars present but verification failed" (hard fail). Don't conflate them — a present-but-invalid signature is a stronger negative signal than no signature at all. + +## SDK behavior + +All three first-party SDKs use this regex when fetching protocol bundles: + +| SDK | Verifies via | +|---|---| +| `@adcp/sdk` (TypeScript) | `scripts/sync-schemas.ts` shells out to `cosign verify-blob` when sidecars are present | +| `adcp-client-python` | `scripts/sync_schemas.py` does the same | +| `adcp-go` | `adcp/schemas/download.sh` does the same | + +If you maintain a fourth-party SDK, mirror the regex above. Stay away from literal-allowlist patterns — they will rot every time a new maintenance branch is cut. + +## Producer-side detail + +If you're contributing to the spec workflow itself: cosign signing happens during `npm run version` (chained from the `sign-protocol-tarball.sh` step) inside `release.yml`. The OIDC token is minted at signing time, so the cert subject reflects the trigger ref of that workflow run. Tag-based signing would require either: + +- A second workflow that runs on `release: published` and re-signs the tarball using the post-tag OIDC subject, or +- Restructuring the release pipeline so signing happens after `changeset tag` and within a context where `refs/tags/*` is the active ref. + +Today's signed-from-branch shape is intentional — it lets every consumer verify a single canonical artifact without reasoning about tag-vs-branch identity. The regex's `refs/(heads|tags)/.*` is forward-compat in case that changes. + +## See also + +- [Schemas, compliance bundles, and SDKs](/dist/docs/3.0.13/building/by-layer/L0/schemas) — where these sidecars are described in the broader bundle-fetching flow +- [Sigstore documentation](https://docs.sigstore.dev/) — keyless signing, transparency log, threat model +- [`adcp#2273`](https://github.com/adcontextprotocol/adcp/issues/2273) — the change that introduced cosign signing diff --git a/dist/docs/3.0.13/reference/versioning.mdx b/dist/docs/3.0.13/reference/versioning.mdx new file mode 100644 index 0000000000..0f78b50730 --- /dev/null +++ b/dist/docs/3.0.13/reference/versioning.mdx @@ -0,0 +1,219 @@ +--- +title: Versioning & Governance +description: "How AdCP versions releases, manages schema changes, and governs protocol evolution." +"og:title": "AdCP — Versioning & Governance" +--- + +AdCP uses a three-tier numbering system: **VERSION.RELEASE.PATCH** (e.g., 3.1.2). + +--- + +## Version tiers + +| Tier | Example | Description | +|------|---------|-------------| +| **Version** | 3.0 → 4.0 | A new generation of the protocol. Reflects accumulated architectural change across the previous cycle, not any single feature. Signals a clean baseline for the ecosystem. | +| **Release** | 3.0 → 3.1 | New fields, new capabilities, or small schema changes that don't alter the protocol's architecture. May affect existing implementations at the margins. | +| **Patch** | 3.1 → 3.1.1 | Bug fixes, clarifications, and corrections. Always safe to upgrade. A patch corrects behavior that diverged from the spec but introduces no new capabilities. | + +### What distinguishes a release from a new version? + +A new version (4.0) ships when the changes are architectural, when cumulative drift from the previous version is large enough that a clean baseline serves the ecosystem, or when there is a strategic reason to signal a new generation. + +A release (3.x) can change schema at the margins — a field's required/optional status, renamed fields with documented aliases, tightened validation, deprecating an object with a replacement available in the same release. These are changes a builder can absorb with targeted updates. + +### Forward compatibility + +Implementations built against 3.0 will continue to function against any 3.x release. Schema changes within a version are designed to be absorbed with targeted updates, not rewrites. + +--- + +## Version negotiation + +When a seller supports multiple major versions simultaneously (e.g., during a v2→v3 migration), buyers and sellers negotiate using `get_adcp_capabilities`: + +1. **Seller advertises**: `adcp.major_versions` in the `get_adcp_capabilities` response lists all supported major versions (e.g., `[2, 3]`). +2. **Buyer declares**: `adcp_major_version` on every request tells the seller which version the buyer's payloads conform to. The field is optional on all AdCP request schemas. +3. **Seller validates**: If the declared version is outside the supported range, the seller returns `VERSION_UNSUPPORTED`. If omitted, the seller assumes its highest supported version. + +This lets sellers upgrade first — declare multi-version support, and let buyers migrate on their own schedule. + +### Features over versions + +Version negotiation handles major architectural boundaries. For feature-level compatibility, use the capability model instead. Sellers declare specific features, targeting systems, execution integrations, and extensions in `get_adcp_capabilities`. Buyers check the capabilities they need and proceed if they're present. + +Not every seller at a given major version will support every feature. Not every buyer needs every feature. The capability contract is: **if declared, the seller MUST honor it**. This gives finer-grained compatibility than version numbers alone. + +See [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities) for the full capability reference. + +--- + +## Schema changes in releases: scope and limits + +Schema changes are accepted in releases under the following conditions: + +**In scope for a release:** + +- Changing a field from optional to required (or vice versa) +- Renaming a field with a documented alias and migration note in the same release +- Tightening validation rules on an existing parameter (documented with before/after examples) +- Deprecating an object or method when a replacement ships in the same release + +**Out of scope — version-level changes only:** + +- Architectural or structural redesign of the protocol +- Removing fields or methods without a prior deprecation release +- Changes to authentication, transport, or core security requirements +- Changes that alter fundamental behavioral semantics + +### Deprecation policy + +Deprecation notices are published at least **6 months** before any feature is removed. Deprecated features remain functional for at least one full release cycle after deprecation, and are never removed within the same major version — a feature deprecated in 3.x will not be removed until 4.0 at the earliest. + +Every release with schema changes is called out in the changelog, release notes, and inline documentation. Every release with schema changes ships with a migration guide. + +[Experimental surfaces](/dist/docs/3.0.13/reference/experimental-status) operate under a separate, faster notice window of 6 weeks. The deprecation policy above applies only to stable surfaces. + +### Spec changes vs. conformance-suite changes + +The release-vs-patch rules above apply to **spec-level artifacts** — JSON Schemas under `static/schemas/source/`, normative prose in `docs/`, and protocol task definitions. These are what an agent implements on the wire and what buyers depend on for interop. + +The **conformance suite** — storyboards, specialism taxonomy, scenario classifications, runner mechanics — versions independently and is **patch-level by default**. The conformance suite is a verification artifact AAO maintains; it is not the spec and it is not the docs. Adding, removing, renaming, or reclassifying preview-status specialisms; relocating storyboards between universal/protocol/specialism directories; refactoring scenario coverage; and adjusting runner behavior to match an unchanged spec are all patch changes. + +A conformance-suite change escalates to minor or major only when it would alter what an agent must do on the wire — i.e., when it tightens implicit spec validation, requires sellers to advertise a new capability that didn't exist, or removes a stable specialism that agents are actively claiming (which is breaking, since agents currently advertising it become non-conformant). + +| Change | Tier | +|--------|------| +| Add a new universal storyboard for an existing capability | Patch | +| Move a storyboard between directories (`specialisms/{id}/` → `universal/`, etc.) | Patch | +| Reclassify a preview-status specialism (no graded users) | Patch | +| Add scenarios within an existing storyboard | Patch | +| Add a new stable specialism to the enum | Minor (new claim agents can make) | +| Remove a stable specialism from the enum | Major (breaks agents currently claiming it) | +| Add a new error code or new optional field to a request/response schema | Minor | + +This separation lets the verification machinery evolve quickly without dragging spec-level versioning along with it. + +--- + +## 3.x stability guarantees + +Implementations built against 3.0 can rely on the following through the 3.x cycle: + +| Artifact | Guarantee within 3.x | +|---|---| +| **Fields** | Never removed. May be renamed with a documented alias that accepts both names in the same release. Optional → required only after a prior deprecation release. | +| **Enums** | Only additive. Existing values are never removed or renamed. Clients must tolerate unknown values and fall back to sensible defaults. | +| **Error codes** | Only additive. Existing codes retain their semantics. Clients that handle unknown error codes generically remain compatible. | +| **Task names** | Never removed or renamed. New tasks may be added. | +| **Authentication, transport, core security** | Never changed. These are version-level changes only. | + +### Experimental surfaces + +The stability guarantees in the table above apply to stable surfaces only. AdCP may publish surfaces as **experimental** when they are part of the core protocol but not yet frozen. Experimental surfaces are marked with `x-status: experimental` in their schemas, and sellers that implement them declare so via `experimental_features` on `get_adcp_capabilities`. + +Experimental surfaces MAY break between any two 3.x releases with at least 6 weeks' notice in the release notes. They graduate to stable once they have demonstrated real-world signal — see the full [experimental status contract](/dist/docs/3.0.13/reference/experimental-status) for the graduation criteria, notice requirements, and client guidance. + +Experimental status is deliberately scoped. If a surface is not marked experimental, the 3.x guarantees above apply. + +### Patch releases + +A patch release (`3.0.1`, `3.1.2`) changes only documentation, wording, or validation that was diverging from the documented spec. Patches never change schema — no new fields, no renamed fields, no new enum values. Upgrading to the latest patch of your current release is always safe. + +### Security fixes + +Security-relevant fixes are documented in release notes with a `security` label and land in the current release. Implementations SHOULD upgrade promptly after a security advisory. Older releases within 3.x do not receive routine backports; upgrading to the current release is the expected remediation path. The same posture applies to v2 during its security-only window — see the [v2 sunset page](/dist/docs/3.0.13/reference/v2-sunset) for that timeline. + +### Breaking-change notice + +Any change that requires an implementation to adapt — renamed field, required-to-optional transition, tightened validation — ships with all of the following: + +- An entry in the [release notes](/dist/docs/3.0.13/reference/release-notes) with a migration note +- An entry in the [changelog](/dist/docs/3.0.13/reference/changelog) +- A section in the [migration guide](/dist/docs/3.0.13/reference/migration) or a dedicated deep-dive page +- Where possible, an alias accepting both old and new names in the release that introduces the change + +--- + +## Release cadence + +AdCP publishes the following named policy so implementers can plan: + +| Commitment | Window | +|---|---| +| **Major releases (breaking)** | minimum 18 months apart | +| **Next major (4.0)** | target early 2027 | +| **Support for previous major after successor GA** | minimum 12 months | +| **Deprecation notice before removal** | minimum 6 months | + +Within a version, releases land every 6–8 weeks early in the cycle and stretch toward quarterly as the version stabilizes. Release count within a version is not fixed. + +### Support window for previous major + +When a new major ships, the prior major receives security patches for at least 12 months after successor GA. Security patches (CVE fixes and security advisories) are backported for the full window; feature work is not. The exact end-of-life date for each version is published on a per-version sunset page and linked from the transition note that accompanies the successor's GA release. + +Deprecation windows do not reset when a deprecated feature is modified — the original removal date holds. + +The v2 sunset is the documented exception to this commitment: v2 predates this policy and lacks account and governance protections that cannot be backported. Its security-only window runs through August 1, 2026 — see the [v2 sunset page](/dist/docs/3.0.13/reference/v2-sunset). Future majors will not invoke this exception. + +### Planned releases + +| Release | Target | +|---------|--------| +| **3.0** | April 2026 — GA release | +| **3.1** | Late June 2026 | +| **3.2** | Late September 2026 | +| **4.0** | Early 2027 — next major, accumulated breaking changes | + +Further 3.x releases are scheduled based on implementation feedback and working group priorities, with at least 8 weeks between releases. Planned scope for each release is tracked on the [GitHub milestones page](https://github.com/adcontextprotocol/adcp/milestones) — open issues on the `3.1.0` and `3.2.0` milestones reflect current candidate work, not fixed commitments. + +--- + +## Extensibility + +AdCP distinguishes three levels of schema extension. The rules below apply to every AdCP schema unless a task reference page states otherwise. + +| Surface | Who may extend | How | +|---|---|---| +| **Core fields** defined by the protocol | Working group, via a release | Proposed in a working group, accepted through the normal release process. Never extended inline by an implementer. | +| **`ext.{namespace}`** on any request or response object | Anyone, by declaring a namespace in `extensions_supported` | Namespaced, out-of-band, does not affect baseline interop. Namespaces SHOULD be registered in the [extension registry](/dist/docs/3.0.13/building/by-layer/L2/context-sessions#extensions). | +| **`additionalProperties: true`** on containers that allow it | Anyone | Additive fields at the declared location. MUST NOT shadow or redefine a core field. Readers MUST ignore unknown fields rather than erroring. When an additional field collides in name with a core field defined on a later release, readers MUST prefer the core field. | + +**What MUST NOT happen:** + +- Introducing new top-level properties on a response schema in place of `ext.{namespace}`. Implementers that need a field outside `ext` MUST propose the field to the working group. +- Renaming a core field inline. Aliases are a spec-level operation, not an implementer operation. +- Tightening a core field's validation locally. An implementer that needs stricter validation performs it before emitting or after receiving the field — not in the wire schema. + +This separation is what lets the spec evolve without every non-conforming implementation becoming a fragmentation claim. The wire contract stays narrow and predictable; `ext.{namespace}` is where anyone can move fast without coordination. + +### Adding to the core protocol + +A field or task enters the core protocol through one of the following paths: + +1. **Release addition.** Additive changes (new optional fields, new tasks, new enum values) ship in a 3.x release under the normal release process. These are stable from the release they ship in. +2. **Experimental addition.** A surface the working group wants to ship for feedback but is not ready to freeze enters the protocol as experimental — see [experimental status](/dist/docs/3.0.13/reference/experimental-status). Experimental surfaces graduate to stable or are removed; there is no permanent experimental state. +3. **Version boundary.** Changes that are incompatible with the current version are held for the next major. See [schema changes in releases](#schema-changes-in-releases-scope-and-limits) for what is in and out of scope for a release. + +--- + +## Governance + +AdCP development is organized around **[working groups](/dist/docs/3.0.13/community/working-group)**, each focused on a specific protocol domain (creative, governance, media buy, signals, brand, sponsored intelligence). Working groups drive feature proposals, surface implementation feedback, and shape the direction of their area. Cross-cutting design decisions — consistency across domains, conflicts between working groups, shared primitives — are resolved in the working-group forums and in public issues on GitHub, not behind closed doors. + +Working groups contribute through: + +- **GitHub Discussions** for proposals and technical debate +- **Slack channels** for real-time collaboration +- **Member feedback** from organizations building on AdCP +- **Reference implementations** that validate design decisions + +As the protocol and its implementation base mature, domain leads will take increasing ownership of their areas. + +--- + +## Additional resources + +- **[Roadmap](/dist/docs/3.0.13/reference/roadmap)** — Planned features and milestones +- **[Release Notes](/dist/docs/3.0.13/reference/release-notes)** — What shipped, with migration guides +- **[Changelog](/dist/docs/3.0.13/reference/changelog)** — Technical change history diff --git a/dist/docs/3.0.13/reference/versions.mdx b/dist/docs/3.0.13/reference/versions.mdx new file mode 100644 index 0000000000..11fe8dd770 --- /dev/null +++ b/dist/docs/3.0.13/reference/versions.mdx @@ -0,0 +1,84 @@ +--- +title: Versions & Compatibility +sidebarTitle: Versions +description: "Every published AdCP version, its status, and its end-of-life date. Start here if you're choosing or upgrading a version." +"og:title": "AdCP — Versions & Compatibility" +--- + +## At a glance + +| Status | Versions | Wire pin | What it means | +|---|---|---|---| +| **Active** | 3.0 (current: 3.0.11) | `"3.0"` | Default for new integrations. Receives feature and security patches. | +| **Security-only** | 2.5.0, 2.5.1, 2.5.3 | `"2.5"` | Security patches through **August 1, 2026 (UTC)**. Not safe for AAO-network production — see [v2 sunset](/dist/docs/3.0.13/reference/v2-sunset). | +| **End of life** | 2.0.0, 2.1.0 | — | No patches. Migrate to 3.0. | +| **Planned** | 3.1, 3.2, 4.0 | — | Targets in [planned releases](/dist/docs/3.0.13/reference/versioning#planned-releases). | + +**Wire values are release-precision only.** Send `"3.0"`, not `"3.0.11"` — a patch component on the wire is rejected. See [patches are not negotiated](/dist/docs/3.0.13/reference/versioning#patches-are-not-negotiated). + +*Status legend: Active = current generation, receiving feature work. Security-only = patches for security issues only, no features. End of life = no patches. Planned = not yet released.* + +## All published versions + +### 3.x — current generation + +| Version | Released | Notes | +|---|---|---| +| 3.0.0 | 2026-04-22 | GA — stable baseline for the 3.x cycle. | +| 3.0.1 through 3.0.10 | 2026-04-28 to 2026-05-10 | Patches. Wire-compatible with 3.0.0. | +| **3.0.11** | **2026-05-11** | **Latest published patch.** | + +Patches don't change the wire contract. Upgrading within 3.0.x is always safe. For per-version detail, see [Release Notes](/dist/docs/3.0.13/reference/release-notes) (curated narrative) and the [Changelog](/dist/docs/3.0.13/reference/changelog) (full technical history). + +### Prereleases (superseded) + +| Version | Released | Notes | +|---|---|---| +| 3.0.0-beta.1 through rc.2 | 2026-01-26 to 2026-03-15 | Superseded. | +| 3.0.0-rc.3 | 2026-04-01 | Superseded by GA. See [prerelease upgrade notes](/dist/docs/3.0.13/reference/migration/prerelease-upgrades) if you adopted rc.3. | + +### 2.x — sunsetting + +| Version | Released | Notes | +|---|---|---| +| 2.0.0 | 2025-10-15 | End of life. No patches. Migrate to 3.0. | +| 2.1.0 | 2025-10-19 | End of life. No patches. Migrate to 3.0. | +| 2.5.0 | 2025-11-22 | Security-only through Aug 1, 2026 (UTC). | +| 2.5.1 | 2025-12-24 | Security-only through Aug 1, 2026 (UTC). | +| 2.5.3 | January 2026 | Security-only through Aug 1, 2026 (UTC). **Last 2.x release.** | + +After August 1, 2026 (UTC), the entire 2.x line is fully deprecated. The 2.x schema URLs continue to resolve so existing integrations don't break silently, but those schemas receive no further updates. See [v2 Sunset](/dist/docs/3.0.13/reference/v2-sunset) for migration paths. + +## FAQ + +### What's "current"? +**3.0.11.** Pin your integration to `"3.0"` (not `"3.0.11"`) — wire values use release precision. See [Version negotiation](/dist/docs/3.0.13/reference/versioning#version-negotiation). + +### What happened to 2.2, 2.3, 2.4, 2.6? +The 2.x line skipped version numbers during its working-group cycle. Some appear as in-flight changelog entries but were never released — the changes were rolled forward into the next published version or deferred to 3.0. The five published 2.x releases are 2.0.0, 2.1.0, 2.5.0, 2.5.1, and 2.5.3. + +### Can I still register a v2 agent? +Yes — v2 agents can be registered in the AAO registry but cannot be AAO Verified. Certification and verified-default discovery require v3.x. See [v2 Sunset → If you use the AAO registry](/dist/docs/3.0.13/reference/v2-sunset#if-you-use-the-aao-registry). + +### How long will 3.0 be supported? +At least 12 months after 4.0 GA. See [Versioning → Support window for previous major](/dist/docs/3.0.13/reference/versioning#support-window-for-previous-major). + +### Where is 4.0? +Targeted for early 2027. 3.x is mid-cycle — see the [planned releases table](/dist/docs/3.0.13/reference/versioning#planned-releases). + +## Choosing a version + +| If you are… | Use | +|---|---| +| Building a new integration today | **3.0** (latest patch). Pin `"3.0"` on the wire. | +| Running a 2.5.x integration | Migrate to 3.0 before **Aug 1, 2026 (UTC)**. Start with the [migration guide](/dist/docs/3.0.13/reference/migration). | +| Running a 2.0 or 2.1 integration | Upgrade now — out of support. | +| Trying an unreleased feature | See [prerelease upgrade notes](/dist/docs/3.0.13/reference/migration/prerelease-upgrades) and [experimental status](/dist/docs/3.0.13/reference/experimental-status). | + +## Related + +- **[Versioning & Governance](/dist/docs/3.0.13/reference/versioning)** — release tiers, schema-change scope, cadence policy +- **[v2 Sunset](/dist/docs/3.0.13/reference/v2-sunset)** — full v2 end-of-life timeline and migration paths +- **[What's new in v3](/dist/docs/3.0.13/reference/whats-new-in-v3)** — feature-by-feature summary of what 3.0 adds +- **[Release Notes](/dist/docs/3.0.13/reference/release-notes)** — curated narrative for each version +- **[Changelog](/dist/docs/3.0.13/reference/changelog)** — full technical change history diff --git a/dist/docs/3.0.13/reference/whats-new-in-3-1.mdx b/dist/docs/3.0.13/reference/whats-new-in-3-1.mdx new file mode 100644 index 0000000000..8f4e2c0158 --- /dev/null +++ b/dist/docs/3.0.13/reference/whats-new-in-3-1.mdx @@ -0,0 +1,242 @@ +--- +title: What's New in AdCP 3.1 +description: "Adopter overview of AdCP 3.1 — distributed brand.json, dependency-impact webhooks, wholesale feed mirroring (conditional fetch, wholesale signals, wholesale feed webhooks), release-precision version negotiation, canonical creative formats, vendor-attested measurement, action discovery, and more. Additive over 3.0 — no breaking changes — but the new surfaces solve real production problems and adopters should upgrade as soon as their SDK pins 3.1." +"og:title": "AdCP — What's New in 3.1" +--- + + +**Status: 3.1 beta.** The spec is feature-complete for 3.1; the published `@adcp/sdk` and the runtime grader are advisory-only against the new surfaces during beta. 3.1 GA target: **2026-05-29**. Adopters can pin `adcp_version: "3.1-beta"` today and migrate the pin to `"3.1"` on GA without code changes. + + +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.** But schemas moved — 3.1 introduces meaningful new surfaces that solve production problems 3.0 left open. **You should bump your pin to 3.1 as soon as your SDK is ready** to pick up the new fields. + +For per-PR detail and migration tables, see [Release Notes § Version 3.1.0](/dist/docs/3.0.13/reference/release-notes#version-3-1-0-unreleased). 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.0.13/reference/whats-new-in-v3). This page covers the 3.0 → 3.1 minor delta only. + + +## 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 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. +- **You can pin a release and stop fighting drift.** Release-precision `adcp_version` (`"3.1"`, `"3.1-beta"`) 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 — no more per-publisher format spaghetti. +- **Action discovery is mechanical.** Products advertise `allowed_actions[]`; media buys carry `available_actions[]`. Buyers pre-flight which mutations are valid instead of failing mid-flight. +- **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 (partners can ask the brand if a claim belongs to it) | +| **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 `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 `pricing_options[]` populated | +| **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) | +| **Version negotiation** | Integer `adcp_major_version` per request | Release-precision `adcp_version` (e.g. `"3.1"`) + `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: `supported_optimization_metrics`, `supported_target_kinds`, `media_buy.frequency_capping`, `media_buy.propagation_surfaces`, `creative.bills_through_adcp`, `capabilities.idempotency.in_flight_max_seconds` | +| **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` responses | +| **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** | — | `x-adcp-hoist` opt-in marker; `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`; storyboard `requires` runtime gate; `comply_test_controller` sandbox gate | + +## 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.0.13/brand-protocol/brand-json#distributed-publishing) · PR [#4505](https://github.com/adcontextprotocol/adcp/pull/4505) + +### `verify_brand_claim` / `verify_brand_claims` — federated brand verification + +Three 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. + +→ Spec: [Brand Protocol § verify_brand_claim](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim) · PRs [#4540](https://github.com/adcontextprotocol/adcp/pull/4540), [#4603](https://github.com/adcontextprotocol/adcp/pull/4603) + +### Dependency-impact webhooks and snapshot coherence + +When a resource a media buy depends on transitions to an offline state — an audience suspended, a creative 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.0.13/media-buy/media-buys/lifecycle#health-impairments) · [Snapshot and log contract](/dist/docs/3.0.13/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.0.13/protocol/snapshot-and-log) · [Webhooks § Persistent channel contract](/dist/docs/3.0.13/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_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.0.13/media-buy/task-reference/get_products#wholesale-feed-versioning) · [`get_products` § Cache layering](/dist/docs/3.0.13/media-buy/task-reference/get_products#cache-layering) · [`get_signals` § Wholesale signals feed](/dist/docs/3.0.13/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) + +### Canonical creative formats — live, 12 canonicals, backwards-compatible + +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`, `responsive_creative`, `sponsored_placement`, `agent_placement`, `custom` — plus `native_in_feed`. Three of those (`sponsored_placement`, `responsive_creative`, `agent_placement`) are tagged **experimental** within the framework; the rest are **stable**. 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; they're surfaced as `runtime_status: declared_only` for routing purposes. + +**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. + +→ Spec: [Canonical formats](/dist/docs/3.0.13/creative/canonical-formats) · PRs [#3307](https://github.com/adcontextprotocol/adcp/pull/3307), [#4770](https://github.com/adcontextprotocol/adcp/pull/4770) + +### Release-precision version negotiation — pin your release + +Every request and response now carries `adcp_version` (release-precision: `"3.1"`, `"3.1-beta"`); 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.0.13/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. + +→ Spec: [Optimization goals § `vendor_metric` kind](/dist/docs/3.0.13/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.0.13/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.0.13/media-buy/advanced-topics/accountability#billing-measurement) · [`report_usage`](/dist/docs/3.0.13/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). + +→ Spec: [Media Buy Lifecycle § Action discovery](/dist/docs/3.0.13/media-buy/media-buys/lifecycle#action-discovery) · 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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/building/verification/compliance-catalog). + +→ Spec: [Compliance catalog](/dist/docs/3.0.13/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) + +### Final-spec clarifications (WG-review batch) + +Ten normative tightenings landed as the spec settled into beta. Mostly low-risk — adopters who'd already inferred reasonable defaults will continue working — but worth knowing about because the grader will check them at GA. + +- **`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 one entry uses `action: "finalize"`, that entry MUST be the only one in the array — multi-finalize is rejected. Multi-finalize across multiple proposals goes through separate `get_products` calls. Atomicity guarantee: finalize either succeeds completely or leaves the proposal unchanged. +- **`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.0.13/reference/migration/media-buy-status). + +→ 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. **`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.0.13/reference/release-notes#version-3-1-0-unreleased) for the per-PR detail. + +## Adopter action + +| If you are… | What you need to do | +|---|---| +| A 3.0-conformant production agent | Nothing required — 3.1 changes are additive and a 3.0 client keeps working. **But you should bump your SDK pin to 3.1 as soon as it's available** to pick up the new fields and emit `adcp_version` for forward-compatibility with future minors. | +| A buyer running production campaigns | Bump your SDK; pass `adcpVersion: "3.1"` (or your release) on construction. 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 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. | +| 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 `measurement.metrics[]` catalog at your AdCP agent. 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. | +| An SDK author | Pin `published_version` to a 3.1 release; emit `adcp_version` (release-precision string) plus `adcp_major_version` (integer mirror); normalize semver values to release-precision before wire emission (`"3.1.0-beta.1"` → `"3.1-beta.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 the 3.1 GA cut. + +**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.0.13/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.0.13/reference/release-notes#version-3-1-0-unreleased). For the version-negotiation cadence and the 3.1 → 3.2 → 4.0 timeline, see [Versioning & Governance § Migration timeline](/dist/docs/3.0.13/reference/versioning#migration-timeline). diff --git a/dist/docs/3.0.13/reference/whats-new-in-v3.mdx b/dist/docs/3.0.13/reference/whats-new-in-v3.mdx new file mode 100644 index 0000000000..002e9736b4 --- /dev/null +++ b/dist/docs/3.0.13/reference/whats-new-in-v3.mdx @@ -0,0 +1,952 @@ +--- +title: AdCP 3.0 +description: "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." +"og:title": "AdCP — AdCP 3.0" +--- + +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.0.13/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.0.13/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 (Ad-ID, 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` by `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[]`). The JWK itself carries `adcp_use: "webhook-signing"` (distinct from `adcp_use: "request-signing"`); `kid` values MUST be unique across purposes within a JWKS. 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.0.13/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.0.13/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/client` / 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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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. + + +--- + +### Signal catalogs for data providers + +Data providers can publish signal catalogs via `adagents.json`, 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 catalog. + + + Complete implementation guide for publishing signal catalogs. + + +--- + +## 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 and account model. Sandbox support moves to `account.sandbox`, and sandbox can participate in the natural account key for implicit-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.0.13/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.0.13/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.0.13/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.0.13/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_major_version`, allowing v3 buyers to declare which major version their payloads conform to. Sellers validate against their `major_versions` and return `VERSION_UNSUPPORTED` if unsupported. When omitted, sellers default to their highest supported version. + +Note: `adcp_major_version` is a v3 field — v2 clients cannot set it (the field does 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 this field. + +### 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: + +- **Ad-ID identifiers** — `industry_identifiers` on creative assets and manifests, with `creative-identifier-type` enum (`ad_id`, `isci`, `clearcast_clock`). Broadcast creatives are identified by Ad-ID, 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, Ad-ID, 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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 now require explicit system specification, supporting global markets. Values are grouped by system using `{ "system": "...", "values": [...] }` objects. + +| v2 field | v3 field | +|----------|----------| +| `geo_metros` (string array) | `geo_metros` (system/values objects) | +| `geo_postal_codes` (string array) | `geo_postal_areas` (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.0.13/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 +{ + "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 both the auth model and account reference style: `true` means explicit accounts (discover via `list_accounts`, pass `account_id`), `false` means implicit 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.0.13/reference/migration/channels) + - [ ] Rename `fixed_rate` -> `fixed_price` in [pricing options](/dist/docs/3.0.13/reference/migration/pricing) + - [ ] Move `price_guidance.floor` -> `floor_price` ([pricing details](/dist/docs/3.0.13/reference/migration/pricing)) + - [ ] Replace `creative_ids` with [`creative_assignments`](/dist/docs/3.0.13/reference/migration/creatives) + - [ ] Add system specification to [metro/postal targeting](/dist/docs/3.0.13/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.0.13/reference/migration/creatives#asset-discovery) + - [ ] Replace `preview_image` reads with [`format_card`](/dist/docs/3.0.13/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.0.13/reference/migration/catalogs) + - [ ] Remove `promoted_offering` string from media buy and creative manifest objects + - [ ] Update `optimization_goal` to [`optimization_goals`](/dist/docs/3.0.13/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.0.13/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.0.13/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.0.13/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.0.13/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 catalogs via `adagents.json`. See [Data Provider Guide](/dist/docs/3.0.13/signals/data-providers). + + - [ ] Create signal catalog 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 catalog 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.0.13/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 buy-side identity. See [brand.json specification](/dist/docs/3.0.13/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.0.13/registry) if not hosting `brand.json` + + + + Implement brand suitability capabilities. See [Governance Protocol](/dist/docs/3.0.13/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.0.13/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.0.13/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 diff --git a/dist/docs/3.0.13/registry/index.mdx b/dist/docs/3.0.13/registry/index.mdx new file mode 100644 index 0000000000..a8d59b8e36 --- /dev/null +++ b/dist/docs/3.0.13/registry/index.mdx @@ -0,0 +1,698 @@ +--- +title: Registry API +sidebarTitle: Overview +description: "Public REST API for brand resolution, property lookup, agent discovery, and authorization in the AdCP ecosystem." +"og:title": "AdCP — Registry API" +--- + +The AgenticAdvertising.org registry provides a public REST API for resolving brands and properties, discovering agents, and validating authorization in the AdCP ecosystem. + +## Base URL + +``` +https://agenticadvertising.org +``` + +Most endpoints are **public and require no authentication**. [Authenticated endpoints](#authenticated-endpoints) require a Bearer token. + +The full [OpenAPI 3.1 specification](https://agenticadvertising.org/openapi/registry.yaml) is available for code generation and tooling. It is also discoverable at `/.well-known/openapi.yaml`. + +## Quick Start + +Resolve a brand domain to its canonical identity: + + + +```bash cURL +curl "https://agenticadvertising.org/api/brands/resolve?domain=acmecorp.com" +``` + +```javascript JavaScript +const res = await fetch( + "https://agenticadvertising.org/api/brands/resolve?domain=acmecorp.com" +); +const brand = await res.json(); +console.log(brand.brand_name, brand.canonical_domain); +``` + +```python Python +import requests + +brand = requests.get( + "https://agenticadvertising.org/api/brands/resolve", + params={"domain": "acmecorp.com"} +).json() +print(brand["brand_name"], brand["canonical_domain"]) +``` + + + +```json Response +{ + "canonical_id": "acmecorp.com", + "canonical_domain": "acmecorp.com", + "brand_name": "Acme Corp", + "keller_type": "master", + "house_domain": "acmecorp.com", + "source": "brand_json" +} +``` + +## Rate Limits + +| Endpoint | Limit | +|----------|-------| +| Bulk resolve (`/api/brands/resolve/bulk`, `/api/properties/resolve/bulk`) | 20 requests/minute per IP | +| Save endpoints (`/api/brands/save`, `/api/properties/save`) | 60 requests/hour per user | +| Crawl request (`/api/registry/crawl-request`) | 5 minutes per domain, 30 requests/hour per user | +| All other endpoints | No limit | + +Rate-limited endpoints return `429 Too Many Requests` when the limit is exceeded. + +## Endpoint Groups + + + + Resolve domains to canonical brand identities, fetch brand.json files, and browse the brand registry. + + + Resolve publisher domains to property information, validate adagents.json, and browse properties. + + + List, search, and filter agents by inventory profile. Browse publishers and view registry statistics. + + + Poll a cursor-based feed of registry changes for local sync. + + + Look up agents by domain, validate product authorization, and check property authorization in real time. + + + +### Brand Resolution + +These endpoints resolve domains to brand identities. The `source` field in the response indicates where the data came from: + +| Source | Meaning | +|--------|---------| +| `brand_json` | Resolved from the domain's `/.well-known/brand.json` file | +| `enriched` | Enriched via Brandfetch API | +| `community` | Submitted by a community member | + +All sources produce the same resolution response structure. To get full brand identity data (logos, colors, tone), use `/api/brands/enrich` or look up the brand in the registry. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/brands/resolve` | Resolve a domain to its canonical brand | +| POST | `/api/brands/resolve/bulk` | Resolve up to 100 domains at once | +| GET | `/api/brands/brand-json` | Fetch raw brand.json for a domain | +| GET | `/api/brands/registry` | List all brands (search, pagination) | +| GET | `/api/brands/enrich` | Enrich brand data via Brandfetch | +| GET | `/api/brands/history` | Edit history for a brand | +| POST | `/api/brands/save` | Save or update a community brand (auth required) | + +### Property Resolution + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/properties/resolve` | Resolve a domain to its property info | +| POST | `/api/properties/resolve/bulk` | Resolve up to 100 domains at once | +| GET | `/api/properties/registry` | List all properties (search, pagination) | +| GET | `/api/properties/validate` | Validate a domain's adagents.json | +| GET | `/api/properties/history` | Edit history for a property | +| POST | `/api/properties/save` | Save or update a hosted property (auth required) | + +### Agent Discovery + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/registry/agents` | List all agents (filter by type, with enrichment) | +| GET | `/api/registry/agents/search` | Search agents by inventory profile (auth required) | +| GET | `/api/registry/publishers` | List all publishers | +| GET | `/api/registry/stats` | Registry statistics | +| POST | `/api/registry/crawl-request` | Request re-crawl of a publisher domain (auth required) | + +### Change Feed + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/registry/feed` | Poll cursor-based registry change feed (auth required) | + + +### Lookups & Authorization + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/registry/lookup/domain/{domain}` | Find agents authorized for a domain | +| GET | `/api/registry/lookup/property` | Find agents by property identifier | +| GET | `/api/registry/lookup/agent/{agentUrl}/domains` | Get all domains for an agent | +| POST | `/api/registry/validate/product-authorization` | Validate agent product authorization | +| POST | `/api/registry/expand/product-identifiers` | Expand property selectors to identifiers | +| GET | `/api/registry/validate/property-authorization` | Real-time authorization check | + +Authorization validation checks both sides: the publisher's `adagents.json` (does it authorize this agent with the claimed `delegation_type`?) and the operator's `brand.json` (does it declare this property with a matching `relationship`?). + +### Validation Tools + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/adagents/validate` | Validate adagents.json for a domain | +| POST | `/api/adagents/create` | Generate adagents.json content | + +### Search + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/search` | Search across brands, publishers, and properties | +| GET | `/api/manifest-refs/lookup` | Find manifest references for a domain | + +### Agent Probing + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/public/discover-agent` | Probe an agent URL for capabilities | +| GET | `/api/public/agent-formats` | Get creative formats from an agent | +| GET | `/api/public/agent-products` | Get products from a sales agent | +| GET | `/api/public/validate-publisher` | Validate a publisher domain | + +## Activity history + +`GET /api/brands/history?domain={domain}` and `GET /api/properties/history?domain={domain}` return the edit history for a registry entry, newest first. These are public endpoints — no authentication required. + +```json Response +{ + "domain": "acmecorp.com", + "total": 3, + "revisions": [ + { + "revision_number": 3, + "editor_name": "Pinnacle Media", + "edit_summary": "Updated logo URL", + "source": "community", + "is_rollback": false, + "created_at": "2026-03-01T12:34:56Z" + }, + { + "revision_number": 2, + "editor_name": "system", + "edit_summary": "API: enriched via Brandfetch", + "source": "enriched", + "is_rollback": false, + "created_at": "2026-02-15T08:00:00Z" + } + ] +} +``` + +Entries with `editor_name: "system"` were written by automated enrichment. When `is_rollback` is `true`, `rolled_back_to` contains the revision number that was restored. Pagination uses `limit` (max 100) and `offset` query parameters. + +## Anti-abuse and anti-homograph controls + +Because `/api/brands/save`, `/api/properties/save`, and the `adagents` validation endpoints accept domain strings from authenticated member organizations, the hosted registry applies a layered floor of anti-abuse controls at save time. These are operational behaviors of the AgenticAdvertising.org registry in the 3.x era — not a new wire surface — and exist so that typosquats, confusable lookalikes, and drive-by brand hijacks cannot get written into the index by a single authenticated caller. + +- **Domain normalization (IDNA 2008 + confusable detection).** Save endpoints SHOULD apply IDNA 2008 to normalize internationalized domain names to ASCII before persistence, and SHOULD then run Unicode confusable-detection (for example, ICU `uspoof` or equivalent) against two corpora: (1) already-registered entries in the index, and (2) a curated high-value-brand deny list maintained by the registry operator. The deny list catches typosquats of well-known brands before those brands themselves are registered (e.g., a `g00gle.com` submission collides with the deny-list entry even if Google has not yet claimed an index row). Ambiguous submissions — mixed-script labels, homograph collisions, disallowed Unicode classes — SHOULD be rejected or flagged for human review rather than silently committed. +- **Ownership proof before commit.** Save endpoints MUST require evidence of domain control before a **new** brand or property entry is committed to the index — this is the threat that motivates the control, since an attacker with a compromised member API key would otherwise be free to bulk-register fresh confusable variants that have no prior entry to conflict with. For **revisions** to an existing community-source entry by the same authenticated organization, re-proof SHOULD be required on a rolling basis (for example, once the prior proof is older than 90 days) but MAY be skipped within that window. Accepted proofs are either a DNS TXT record at `_adcp-owner.{domain}` matching a server-issued nonce, or an HTTP challenge hosted at `/.well-known/adcp-ownership.txt` on the domain. Nonces MUST be single-use, scoped to the `(organization, domain)` pair, and MUST expire within **15 minutes** of issuance; verification MUST consume the nonce on success and invalidate it on failure. A leaked or unused nonce after expiry is dead. Revisions to an existing **authoritative** entry (i.e., one backed by `brand.json` / `adagents.json`) continue to follow the 409 Conflict semantics in [Save brand](#save-brand) and [Save property](#save-property); ownership proof covers the community-source save path. +- **Per-organization rate limits on saves.** In addition to the per-IP rate limits documented in [Rate Limits](#rate-limits), save endpoints SHOULD apply per-organization limits so that a single compromised API key cannot bulk-register confusable variants. The hosted implementation uses a burst-tolerant cap (indicative: tens of saves per hour per org, low-hundreds per day per org); callers exceeding the per-org bucket receive `429 Too Many Requests`. + +These controls are enforced by the hosted AgenticAdvertising.org registry. Self-hosted mirrors consuming the [Change Feed](#change-feed) rely on the hosted registry's save-time checks and do not re-run them — which is consistent with the feed's advisory-identity posture (see `specs/registry-change-feed.md` §Advisory identity material): the feed is change-detection, not a trust anchor, and the publisher's own `adagents.json` pin remains the authoritative identity source (see [`adagents.json` §`signing_keys`](/dist/docs/3.0.13/governance/property/adagents#signing_keys)). Operators running an alternative registry implementation SHOULD apply equivalent save-time controls before accepting community-source writes. + +## Authentication + +Public endpoints (resolution, discovery, search) require no authentication. Write endpoints accept either an **organization API key** (server-to-server) or a **user JWT** obtained via OAuth 2.1 (interactive / agent clients). Both are sent in the `Authorization: Bearer ...` header. + +### Option A: Organization API key + +Long-lived, org-scoped. Best for server-to-server integrations where no user is present. + +1. Sign in at [agenticadvertising.org/dashboard/api-keys](https://agenticadvertising.org/dashboard/api-keys) +2. Click **Create key** and copy the generated key + +Pass the key in the `Authorization` header: + +``` +Authorization: Bearer sk_... +``` + +### Option B: User SSO via OAuth 2.1 + +Short-lived, user-scoped. Best for agent clients (MCP, AI assistants, custom apps) where a human is signing in to AAO. A single token works against both `/mcp` and the REST API. + +Discovery follows [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) and [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728): + +- Authorization server metadata: `GET /.well-known/oauth-authorization-server` +- Protected-resource metadata (REST API): `GET /.well-known/oauth-protected-resource/api` +- Protected-resource metadata (MCP): `GET /.well-known/oauth-protected-resource/mcp` + +The flow is authorization code with PKCE. Dynamic client registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) is available at `/register`. Users authenticate via AuthKit; the token is a WorkOS-signed JWT. + +``` +Authorization: Bearer +``` + +A valid user JWT proves identity, not entitlement. Endpoints gated on organization membership or admin role (most write endpoints) still return `403` if the authenticated user lacks the required standing. + +### Authenticated endpoints + +These endpoints require a valid API key. + +#### Save brand + +`POST /api/brands/save` + +Save or update a community brand in the registry. For existing brands, creates a revision-tracked edit. Cannot edit authoritative brands managed via `brand.json` — those return `409 Conflict`. + +**Request body:** + +```json +{ + "domain": "acmecorp.com", + "brand_name": "Acme Corp", + "brand_manifest": { + "name": "Acme Corp", + "description": "A fictional company", + "logos": [{ "url": "https://acmecorp.com/logo.svg", "tags": ["icon"] }], + "colors": [{ "hex": "#FF5733", "type": "accent" }] + } +} +``` + +`domain` and `brand_name` are required. `brand_manifest` (brand identity data) is optional. The brand's `source` is set to `"community"` by the server. Domains are normalized (protocol stripped, lowercased). + + + +```bash cURL +curl -X POST "https://agenticadvertising.org/api/brands/save" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"domain":"acmecorp.com","brand_name":"Acme Corp"}' +``` + +```javascript JavaScript +const res = await fetch( + "https://agenticadvertising.org/api/brands/save", + { + method: "POST", + headers: { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + domain: "acmecorp.com", + brand_name: "Acme Corp", + }), + } +); +const result = await res.json(); +``` + +```python Python +import requests + +result = requests.post( + "https://agenticadvertising.org/api/brands/save", + headers={"Authorization": "Bearer YOUR_API_KEY"}, + json={"domain": "acmecorp.com", "brand_name": "Acme Corp"}, +).json() +``` + + + +```json Response (create) +{ + "success": true, + "message": "Brand \"Acme Corp\" saved to registry", + "domain": "acmecorp.com", + "id": "br_abc123" +} +``` + +```json Response (update) +{ + "success": true, + "message": "Brand \"Acme Corp\" updated in registry (revision 2)", + "domain": "acmecorp.com", + "id": "br_abc123", + "revision_number": 2 +} +``` + +#### Save property + +`POST /api/properties/save` + +Save or update a hosted property in the registry. For existing properties, creates a revision-tracked edit. Cannot edit authoritative properties managed via `adagents.json` — those return `409 Conflict`. + +**Request body:** + +```json +{ + "publisher_domain": "examplepub.com", + "authorized_agents": [ + { "url": "https://agent.example.com", "authorized_for": "sell" } + ], + "properties": [ + { "type": "website", "name": "Example Publisher" } + ], + "contact": { + "name": "Ad Ops", + "email": "adops@examplepub.com" + } +} +``` + +`publisher_domain` and `authorized_agents` (each with a required `url` and optional `authorized_for`) are required. `properties` (each requiring `type` and `name`) and `contact` are optional. Domains are normalized (protocol stripped, lowercased). + + + +```bash cURL +curl -X POST "https://agenticadvertising.org/api/properties/save" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "publisher_domain": "examplepub.com", + "authorized_agents": [{"url": "https://agent.example.com", "authorized_for": "sell"}], + "properties": [{"type": "website", "name": "Example Publisher"}] + }' +``` + +```javascript JavaScript +const res = await fetch( + "https://agenticadvertising.org/api/properties/save", + { + method: "POST", + headers: { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + publisher_domain: "examplepub.com", + authorized_agents: [{ url: "https://agent.example.com", authorized_for: "sell" }], + properties: [{ type: "website", name: "Example Publisher" }], + }), + } +); +const result = await res.json(); +``` + +```python Python +import requests + +result = requests.post( + "https://agenticadvertising.org/api/properties/save", + headers={"Authorization": "Bearer YOUR_API_KEY"}, + json={ + "publisher_domain": "examplepub.com", + "authorized_agents": [{"url": "https://agent.example.com", "authorized_for": "sell"}], + "properties": [{"type": "website", "name": "Example Publisher"}], + }, +).json() +``` + + + +```json Response (create) +{ + "success": true, + "message": "Hosted property created for examplepub.com", + "id": "prop_xyz789" +} +``` + +```json Response (update) +{ + "success": true, + "message": "Property 'examplepub.com' updated (revision 2)", + "id": "prop_xyz789", + "revision_number": 2 +} +``` + +#### Change feed + +`GET /api/registry/feed` + +Poll a cursor-based feed of registry changes. Use this to keep a local copy of the registry in sync without re-fetching the full dataset. Events are ordered by UUID v7 `event_id`, providing monotonic cursor progression. The feed retains events for 90 days — expired cursors return `410 Gone`. + +**Query parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `cursor` | UUID | — | Resume after this event ID. Omit for the earliest available events. | +| `types` | string | — | Comma-separated event type filters. Supports glob patterns (e.g. `property.*`). | +| `limit` | number | 100 | Max events per page (1–10,000). | + +**Event types:** + +| Type | Description | +|------|-------------| +| `property.created` | A new property was added to the registry | +| `property.updated` | Property metadata changed | +| `property.merged` | Two property records were merged | +| `property.stale` | Property failed re-crawl validation | +| `property.reactivated` | A stale property passed re-crawl | +| `agent.discovered` | A new agent was found via adagents.json | +| `agent.removed` | An agent was removed from the registry | +| `agent.profile_updated` | Agent inventory profile changed | +| `publisher.adagents_changed` | A publisher's adagents.json was updated | +| `authorization.granted` | An agent was authorized for a property | +| `authorization.revoked` | An authorization was removed | + + + +```bash cURL +curl "https://agenticadvertising.org/api/registry/feed?types=property.*&limit=50" \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +```javascript JavaScript +const res = await fetch( + "https://agenticadvertising.org/api/registry/feed?types=property.*&limit=50", + { headers: { Authorization: "Bearer YOUR_API_KEY" } } +); +const feed = await res.json(); +// Store feed.cursor for next poll +``` + +```python Python +import requests + +feed = requests.get( + "https://agenticadvertising.org/api/registry/feed", + headers={"Authorization": "Bearer YOUR_API_KEY"}, + params={"types": "property.*", "limit": 50}, +).json() +# Store feed["cursor"] for next poll +``` + + + +```json Response +{ + "events": [ + { + "event_id": "019539a0-1234-7000-8000-000000000001", + "event_type": "property.created", + "entity_type": "property", + "entity_id": "019539a0-b1c2-7000-8000-000000000002", + "payload": {}, + "actor": "crawler", + "created_at": "2026-03-31T10:00:00.000Z" + } + ], + "cursor": "019539a0-1234-7000-8000-000000000001", + "has_more": true +} +``` + +When `has_more` is `true`, pass the returned `cursor` value in the next request to continue polling. When `false`, you've reached the end of the current feed — poll again later with the same cursor to pick up new events. + +If the cursor has expired (older than 90 days or not found), the response is `410 Gone`: + +```json 410 Gone +{ + "error": "cursor_expired", + "message": "Cursor is older than 90-day retention window. Re-bootstrap from /registry/agents/search and /catalog/sync." +} +``` + +#### Agent search + +`GET /api/registry/agents/search` + +Search agents by inventory profile — channels, markets, content categories, property types, and more. Filters use AND across dimensions and OR within a dimension. Results are ranked by a relevance score based on filter match breadth, inventory depth, and TMP support. + +**Query parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `channels` | CSV | — | Filter by channel (e.g. `ctv,olv,display`) | +| `property_types` | CSV | — | Filter by property type (e.g. `ctv_app,website`) | +| `markets` | CSV | — | Filter by market/country code (e.g. `US,GB`) | +| `categories` | CSV | — | Filter by IAB content category (e.g. `IAB-7,IAB-7-1`) | +| `tags` | CSV | — | Filter by tag (e.g. `premium,brand_safe`) | +| `delivery_types` | CSV | — | Filter by delivery type (e.g. `guaranteed,programmatic`) | +| `has_tmp` | boolean | — | Require TMP support (`true` or `false`) | +| `min_properties` | number | — | Minimum number of properties in inventory | +| `cursor` | string | — | Pagination cursor from a previous response | +| `limit` | number | 50 | Max results per page (1–200) | + +Each CSV parameter accepts up to 100 values. + + + +```bash cURL +curl "https://agenticadvertising.org/api/registry/agents/search?channels=ctv,olv&markets=US&has_tmp=true" \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +```javascript JavaScript +const res = await fetch( + "https://agenticadvertising.org/api/registry/agents/search?channels=ctv,olv&markets=US&has_tmp=true", + { headers: { Authorization: "Bearer YOUR_API_KEY" } } +); +const agents = await res.json(); +``` + +```python Python +import requests + +agents = requests.get( + "https://agenticadvertising.org/api/registry/agents/search", + headers={"Authorization": "Bearer YOUR_API_KEY"}, + params={"channels": "ctv,olv", "markets": "US", "has_tmp": "true"}, +).json() +``` + + + +```json Response +{ + "results": [ + { + "agent_url": "https://ads.streamhaus.example.com", + "channels": ["ctv", "olv"], + "property_types": ["ctv_app", "website"], + "markets": ["US", "GB", "CA"], + "categories": ["IAB-7", "IAB-7-1"], + "tags": ["premium"], + "delivery_types": ["guaranteed"], + "format_ids": [], + "property_count": 42, + "publisher_count": 3, + "has_tmp": true, + "category_taxonomy": null, + "relevance_score": 0.92, + "matched_filters": ["channels", "markets"], + "updated_at": "2026-03-31T10:00:00.000Z" + } + ], + "cursor": "MC45Mjpodh...", + "has_more": false +} +``` + +The `matched_filters` array shows which filter dimensions matched, useful for understanding why a result was returned. The `relevance_score` combines filter match breadth, `ln(property_count + 1)` weighted at 0.1, and a 0.05 boost for TMP support. + +#### Crawl request + +`POST /api/registry/crawl-request` + +Request an immediate re-crawl of a publisher domain. Use this after updating an `adagents.json` file so the registry picks up changes without waiting for the next scheduled crawl. The crawl runs asynchronously — the endpoint returns `202 Accepted` immediately. + +Rate-limited to one request per domain every 5 minutes and 30 requests per user per hour. + +**Request body:** + +```json +{ + "domain": "examplepub.com" +} +``` + +`domain` is required. Domains are normalized (lowercased, trimmed). The endpoint validates the domain format and performs a DNS lookup to reject private/reserved IP addresses. + + + +```bash cURL +curl -X POST "https://agenticadvertising.org/api/registry/crawl-request" \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"domain":"examplepub.com"}' +``` + +```javascript JavaScript +const res = await fetch( + "https://agenticadvertising.org/api/registry/crawl-request", + { + method: "POST", + headers: { + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + }, + body: JSON.stringify({ domain: "examplepub.com" }), + } +); +const result = await res.json(); // 202 +``` + +```python Python +import requests + +result = requests.post( + "https://agenticadvertising.org/api/registry/crawl-request", + headers={"Authorization": "Bearer YOUR_API_KEY"}, + json={"domain": "examplepub.com"}, +).json() +``` + + + +```json 202 Accepted +{ + "message": "Crawl request accepted", + "domain": "examplepub.com" +} +``` + +```json 429 Too Many Requests +{ + "error": "Rate limit exceeded for this domain", + "retry_after": 245 +} +``` + +`retry_after` is the number of seconds to wait before retrying. + +#### Submit brand (legacy) + +`POST /api/brands/discovered/community` + +Submit a brand for review. This endpoint predates `/api/brands/save` — prefer the save endpoint for new integrations. + +### Error responses + +| Status | Description | +|--------|-------------| +| 400 | Missing required fields or invalid domain | +| 401 | Missing or invalid API key | +| 409 | Cannot edit an authoritative brand/property (managed via `brand.json` or `adagents.json`) | +| 410 | Cursor expired (change feed — older than 90-day retention window) | +| 429 | Rate limit exceeded | + +## Protocol vs REST API + +The AdCP protocol defines MCP and A2A tasks for agent-to-agent communication (e.g. `get_products`, `create_media_buy`). The registry REST API is separate — it provides HTTP endpoints for looking up entities in the AgenticAdvertising.org registry. + +**Use the REST API** for discovery and authorization: +- Resolve brand or property domains before making protocol calls +- Discover which agents exist and what they're authorized for +- Validate authorization in real time during ad serving +- Build integrations that browse or search the registry + +**Use MCP/A2A tasks** for transactional operations: +- Fetching products from a sales agent (`get_products`) +- Creating media buys (`create_media_buy`) +- Building creatives (`build_creative`) +- Getting signals (`get_signals`) + +A typical integration uses both: resolve a publisher domain via the registry API, then call the authorized agent's MCP endpoint to transact. diff --git a/dist/docs/3.0.13/registry/registering-an-agent.mdx b/dist/docs/3.0.13/registry/registering-an-agent.mdx new file mode 100644 index 0000000000..c1b38c4a70 --- /dev/null +++ b/dist/docs/3.0.13/registry/registering-an-agent.mdx @@ -0,0 +1,116 @@ +--- +title: Registering an agent +sidebarTitle: Registering an agent +description: "How agents appear in the AAO registry — the single enrollment path and what AAO membership unlocks." +"og:title": "AdCP — Registering an agent" +--- + +The AdCP registry catalog (`/api/registry/agents`, `/api/registry/publishers`) contains only AAO-attested, member-enrolled agents. To appear in the catalog, an AAO member must explicitly enroll the agent on their member profile. + +## How agents end up in the registry + +There is one path: **an AAO member adds the agent to their member profile** via the dashboard or `PUT /api/me/member-profile`. End-to-end via the dashboard: under five minutes. + +1. **Sign in or sign up.** Go to [agenticadvertising.org/auth/login](https://agenticadvertising.org/auth/login). New here? Use **Sign up** to create your account, then accept your AAO organization invite (or [start a membership](https://agenticadvertising.org/membership) if your org isn't on AAO yet). +2. **Open the agents dashboard.** Once signed in, go to [agenticadvertising.org/dashboard/agents](https://agenticadvertising.org/dashboard/agents). The URL auto-resolves your org context. +3. **Click `+ Register agent`** in the top-right of the page. (On a brand-new org with no agents yet, the empty-state CTA reads **Register your first agent** and triggers the same flow.) +4. **Talk to Addie.** The button drops you into chat with Addie pre-loaded with the prompt *"Help me register my agent."* Addie will walk you through: + - **Agent URL** — e.g. `https://agent.yourcompany.com/mcp` + - **Display name** (optional) + - **Auth method** — pick one: None · Static bearer · Static basic · OAuth client credentials. *(Interactive OAuth user authorization is configured separately — register with **None** here, then click **Authorize** on the agent card to sign in.)* + - **Auth fields** — only the ones your chosen method needs (bearer token, or `token_endpoint` + `client_id` + `client_secret` for client credentials, etc.) + - **Protocol** — defaults to MCP; Addie asks only if your URL is ambiguous +5. **Done.** Addie calls `save_agent` and your agent lands in the registry catalog with `visibility: "members_only"` (visible to other paying AAO members — Professional, Builder, Member, or Leader; not publicly listed). +6. **Optional — go public.** Back on `/dashboard/agents`, change the agent's visibility from **Members only** to **Public**. Public visibility requires a paid AAO tier (Professional, Builder, Member, or Leader) and a primary brand domain on your member profile so the agent can be added to your `brand.json`. + +**Type is resolved server-side.** You will not be asked for the agent's type — type (`brand`, `sales`, `buying`, `measurement`, `creative`, `signals`, etc.) is resolved from the agent's capability snapshot. `resolveAgentTypes()` reads the most recent snapshot from the crawler; if no snapshot exists yet, the type field saved is whatever the client supplied, and the next crawler probe overwrites it. Either way, you can't pin a wrong type permanently. + +**What this path attests:** the member has signed AAO terms; the URL, name, and contact are explicitly declared; the type is probe-verified. Visibility can be `public`, `members_only`, or `private` — see [Visibility](#visibility) below. + +There is no auto-population from crawled `adagents.json` files. Agents listed in third-party `adagents.json` files populate the publisher-authorization graph used by the Operator lookup endpoint (`GET /api/registry/operator?domain=X`), `/api/registry/lookup/domain`, and `hasValidAdagents`, but they do not create catalog entries. + +## Programmatic registration (for CI, scripts, agents) + +To register agents from CI, a deploy hook, or your own agent — without the dashboard or Addie — use the per-agent REST endpoints under `/api/me/agents`. They share the same visibility gate, server-side type resolution, and audit log as the dashboard path, so the `members_only` default, the `tier_required` check on `public`, and the type smuggle-protection all apply identically. + +Authenticate with a WorkOS API key (`Authorization: Bearer sk_…`) or an OAuth user JWT. Mint an API key under [agenticadvertising.org/dashboard/api-keys](https://agenticadvertising.org/dashboard/api-keys). + +| Endpoint | Purpose | +|---|---| +| `GET /api/me/agents` | List my registered agents | +| `POST /api/me/agents` | Register an agent | +| `PATCH /api/me/agents/{url}` | Update an agent | +| `DELETE /api/me/agents/{url}` | Remove an agent | + +The path parameter on PATCH and DELETE is the agent's `url`, URL-encoded (e.g. `https%3A%2F%2Fagent.example.com%2Fmcp`). `POST` is idempotent on `url`: new entries return `201`; re-posting the same `url` updates the existing entry and returns `200`. Each successful write returns `{ agent, warnings? }` — `warnings` lists any tier-driven visibility downgrades (e.g. an Explorer-tier caller asking for `public` is stored as `members_only` with a `visibility_downgraded` warning). + +### Prerequisites + +- An AAO member profile must already exist for your organization. Create one via the dashboard or `POST /api/me/member-profile` first; the agent endpoints return `404` until then. +- For `visibility: "public"`, your organization needs a paid AAO tier (Professional, Builder, Member, or Leader) and a `primary_brand_domain` set on the profile so the agent can be added to your `brand.json`. See [Visibility](#visibility) below. + +## What this means if you are not an AAO member + +You cannot self-register today. Your operator must be an AAO member to enroll your agent in the registry catalog. + +Crawl-discovered listings (your agent referenced in someone's `adagents.json`) populate the authorization graph used for property-authorization checks but do not create a catalog entry — `/api/registry/agents` is members-only. + +[Become a member](https://agenticadvertising.org/membership) to access the registration path. + +## Visibility + +Member-enrolled agents have one of three visibility levels: + +| Visibility | Who sees it | +|---|---| +| `public` | Anyone — anonymous calls to `/api/registry/agents` and `/api/registry/operator?domain=X` | +| `members_only` | AAO API-tier members on `/api/registry/operator?domain=X` | +| `private` | Profile owner only | + +The `/api/registry/operator?domain=X` endpoint is auth-aware: anonymous callers see only `public` agents; authenticated AAO API-tier callers see `members_only` agents; profile owners additionally see `private` agents. + +## Membership benefits + +| Capability | Description | +|---|---| +| Catalog visibility | Your agent appears in `/api/registry/agents` | +| Self-attest type, name, contact | You declare and edit the agent's identity | +| Edit your own listing | Profile owner controls all fields | +| `members_only` and `private` visibility | Scope agent visibility beyond `public` | +| AAO Verified badge | Eligible after passing storyboards | +| Storyboard test access | Run protocol-conformance tests against your agent | +| Compliance reporting | Reports against your agent's protocol use | +| Trust signal to consumers | "Member of AAO; signed terms; attested" | + +[Become a member](https://agenticadvertising.org/membership) to access the registered path. + +## Verifying how your agent appears + +Query the registry directly: + + + +```bash cURL +curl "https://agenticadvertising.org/api/registry/agents" \ + | jq '.agents[] | select(.url == "https://your-agent-url.example/mcp")' +``` + +```javascript JavaScript +const res = await fetch("https://agenticadvertising.org/api/registry/agents"); +const { agents } = await res.json(); +const yours = agents.find((a) => a.url === "https://your-agent-url.example/mcp"); +console.log(yours); +``` + + + +If your agent's URL is in the response, it is enrolled and `member` identifies the AAO organization that owns the listing. If it is not in the response, no AAO member has enrolled it — ask your operator to enroll it via their member profile, or [become a member](https://agenticadvertising.org/membership) and self-enroll. + +To check whether your agent is referenced in a publisher's `adagents.json` (for authorization purposes, separate from catalog enrollment), call `/api/registry/lookup/domain/{domain}` against the publisher's domain. + +## Related + +- [Registry overview](/dist/docs/3.0.13/registry) — endpoint catalog, lookup flows, and brand resolution. +- `GET /api/registry/operator?domain=X` — auth-aware per-entity view of agents and authorizations. +- `GET /api/registry/agents` — full registry catalog. +- `POST /api/registry/recrawl` — refresh a publisher's `adagents.json` mapping in the authorization graph. diff --git a/dist/docs/3.0.13/signals/data-providers.mdx b/dist/docs/3.0.13/signals/data-providers.mdx new file mode 100644 index 0000000000..5afcbe9cdb --- /dev/null +++ b/dist/docs/3.0.13/signals/data-providers.mdx @@ -0,0 +1,647 @@ +--- +title: Data Provider Guide +sidebarTitle: Data Providers +description: "Publish a signal catalog as a data provider using adagents.json. Define signal value types, authorize signal agents, and enable AI-driven audience discovery and verification through AdCP." +"og:title": "AdCP — Data Provider Guide" +--- + +# Data Provider Guide + +This guide explains how data providers publish signal catalogs via `adagents.json`, enabling AI agents to discover, verify authorization, and activate signals for advertising campaigns. + +## The Problem + +Data providers (Pinnacle Data, Meridian Analytics, Apex Segments, etc.) own valuable audience and contextual data, but integrating with the growing ecosystem of AI-powered advertising agents presents challenges: + +**Discovery is fragmented.** Each signals agent (Luminary Data, Nova DSP, etc.) needs custom integrations to know what signals you offer. There's no standard way for an AI agent to ask "what automotive purchase intent signals does Pinnacle Data have?" + +**Authorization is opaque.** When a buyer receives a signal from a signals agent, they can't verify that the agent is actually authorized to resell it. They have to trust the intermediary. + +**Signal semantics are inconsistent.** Without standardized definitions, an AI agent can't know whether "auto_intenders" is a binary segment, a propensity score, or a multi-value category—making it impossible to construct proper targeting expressions. + +**Scaling requires N×M integrations.** Every data provider needs custom integrations with every signals agent. This doesn't scale. + +## The Solution + +Signal Catalogs solve these problems by letting data providers publish a machine-readable catalog of their signals at a well-known URL. This enables: + +- **Discovery**: AI agents can find signals via natural language ("find automotive purchase intent signals") or structured lookup +- **Authorization verification**: Buyers can verify authorization by checking the data provider's domain directly +- **Typed targeting**: Signal definitions include value types (binary, categorical, numeric) so agents can construct correct targeting expressions +- **Scalable partnerships**: Authorize agents once in your catalog; as you add signals, authorized agents automatically have access + +## Overview + +Data providers own audience and contextual data (purchase intent, demographics, behavioral segments). The Signal Catalog feature lets you publish your signals in a standardized format that: + +- Enables discovery via natural language queries +- Provides authorization verification for agents +- Describes signal characteristics (binary, categorical, numeric) +- Supports tag-based grouping for efficient authorization + +This follows the same pattern as publishers declaring properties - instead of "what ad placements exist," you're declaring "what signals exist." + +## The Parallel Pattern + +| Publishers | Data Providers | +|------------|----------------| +| Declare **properties** (websites, apps) | Declare **signals** (audiences, segments) | +| Authorize agents to **sell inventory** | Authorize agents to **resell signals** | +| Use `property_ids` / `property_tags` | Use `signal_ids` / `signal_tags` | +| Buyers verify via `publisher_domain` | Buyers verify via `data_provider_domain` | + +Both use `/.well-known/adagents.json` as the publishing mechanism. A single `adagents.json` file can declare both `properties` and `signals` simultaneously — see [Unified declaration model](/dist/docs/3.0.13/governance/property/adagents#unified-declaration-model). + +## File Location + +Data providers host their signal catalog at: + +``` +https://your-domain.com/.well-known/adagents.json +``` + +Following [RFC 8615](https://datatracker.ietf.org/doc/html/rfc8615) well-known URI conventions. + +## Basic Structure + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/adagents.json", + "contact": { + "name": "Pinnacle Auto Data", + "email": "partnerships@pinnacle-auto-data.com", + "domain": "pinnacle-auto-data.com" + }, + "signals": [ + { + "id": "likely_ev_buyers", + "name": "Likely EV Buyers", + "description": "Consumers modeled as likely to purchase an electric vehicle in the next 12 months", + "value_type": "binary", + "tags": ["automotive", "green"] + } + ], + "signal_tags": { + "automotive": { + "name": "Automotive Signals", + "description": "Vehicle-related audience segments" + }, + "green": { + "name": "Green/Sustainability", + "description": "Environmentally-conscious consumer segments" + } + }, + "authorized_agents": [ + { + "url": "https://signals-agent.example.com", + "authorized_for": "All automotive signals", + "authorization_type": "signal_tags", + "signal_tags": ["automotive"] + } + ], + "last_updated": "2025-01-15T10:00:00Z" +} +``` + +## Signal Definition + +Each signal in the `signals` array describes a targetable segment: + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Unique identifier within your catalog. Pattern: `^[a-zA-Z0-9_-]+$` | +| `name` | string | Human-readable signal name | +| `value_type` | enum | Data type: `binary`, `categorical`, or `numeric` | + +### Optional Fields + +| Field | Type | Description | +|-------|------|-------------| +| `description` | string | Detailed description of what this signal represents | +| `tags` | array | Tags for grouping (lowercase, alphanumeric: `^[a-z0-9_-]+$`) | +| `allowed_values` | array | For categorical signals: valid values | +| `range` | object | For numeric signals: `{ min, max, unit }` | +| `restricted_attributes` | array | Restricted attribute categories this signal touches (e.g., `["health_data"]`). Enables structural governance matching. | +| `policy_categories` | array | Policy categories this signal is sensitive for (e.g., `["children_directed"]`). Enables structural governance matching. | + +## Signal Value Types + +### Binary Signals + +User either matches or doesn't. Most common type. + +```json +{ + "id": "likely_ev_buyers", + "name": "Likely EV Buyers", + "value_type": "binary", + "tags": ["automotive", "purchase_intent"] +} +``` + +**Targeting**: Include or exclude users matching this signal. + +### Categorical Signals + +User has one of several possible values. + +```json +{ + "id": "vehicle_ownership", + "name": "Current Vehicle Ownership", + "value_type": "categorical", + "allowed_values": ["luxury_ev", "luxury_non_ev", "mid_range", "economy", "none"] +} +``` + +**Targeting**: Target users with specific values (e.g., "users who own a luxury EV or luxury non-EV"). + +### Numeric Signals + +User has a score or measurement within a range. + +```json +{ + "id": "purchase_propensity", + "name": "Auto Purchase Propensity", + "value_type": "numeric", + "range": { + "min": 0, + "max": 1, + "unit": "score" + } +} +``` + +**Targeting**: Target users within a value range (e.g., "propensity score > 0.7"). + +## Authorization Patterns + +### Pattern 1: Signal IDs (Direct References) + +Authorize specific signals by ID: + +```json +{ + "authorized_agents": [ + { + "url": "https://premium-agent.example.com", + "authorized_for": "Premium automotive signals only", + "authorization_type": "signal_ids", + "signal_ids": ["likely_ev_buyers", "luxury_auto_intenders"] + } + ] +} +``` + +**Best for**: Specific, limited signal sets. Fine-grained control. + +### Pattern 2: Signal Tags (Efficient Grouping) + +Authorize all signals with certain tags: + +```json +{ + "authorized_agents": [ + { + "url": "https://full-catalog-agent.example.com", + "authorized_for": "All automotive signals", + "authorization_type": "signal_tags", + "signal_tags": ["automotive"] + } + ] +} +``` + +**Best for**: Large catalogs. As you add signals with the tag, agents automatically get access. + +## Signal Tags + +The `signal_tags` object provides metadata for tags used in signals: + +```json +{ + "signal_tags": { + "automotive": { + "name": "Automotive Signals", + "description": "Vehicle ownership, purchase intent, and service signals" + }, + "premium": { + "name": "Premium Signals", + "description": "High-value segments with enhanced pricing" + } + } +} +``` + +**Why define tags?** +- Human-readable context for buyers exploring your catalog +- Enables efficient authorization ("all premium signals") +- Groups related signals for easier discovery + +## How Buyers Use Your Catalog + +### 1. Discovery + +Buyers call `get_signals` on a signals agent. The agent may use your catalog for: +- Natural language matching ("find automotive purchase intent signals") +- Structured lookup by `signal_id` + +### 2. Authorization Verification + +When a buyer receives a signal, they can verify authorization: + +```json +{ + "signal_id": { + "data_provider_domain": "pinnacle-auto-data.com", + "id": "likely_ev_buyers" + } +} +``` + +The buyer fetches `https://pinnacle-auto-data.com/.well-known/adagents.json` and checks: +1. Does the signal exist in the `signals` array? +2. Is the signals agent in `authorized_agents`? +3. Does the authorization cover this signal (by ID or tag)? + +### 3. Targeting + +Based on `value_type`, buyers construct targeting expressions: + +```json +// Binary targeting +{ + "signal_id": { "source": "catalog", "data_provider_domain": "pinnacle-auto-data.com", "id": "likely_ev_buyers" }, + "value_type": "binary", + "value": true +} + +// Categorical targeting +{ + "signal_id": { "source": "catalog", "data_provider_domain": "pinnacle-auto-data.com", "id": "vehicle_ownership" }, + "value_type": "categorical", + "values": ["luxury_ev", "luxury_non_ev"] +} + +// Numeric targeting +{ + "signal_id": { "source": "catalog", "data_provider_domain": "pinnacle-auto-data.com", "id": "purchase_propensity" }, + "value_type": "numeric", + "min_value": 0.7 +} +``` + +## Agent-Native Signals + +Not all signals come from data provider catalogs. Signals agents may also offer **agent-native signals** - custom signals they've created themselves (proprietary models, first-party data, etc.). + +### Signal ID Structure + +Signal IDs use `source` as a discriminator: + +| Source | Fields | Verification | +|--------|--------|--------------| +| `catalog` | `data_provider_domain` + `id` | Verifiable via data provider's adagents.json | +| `agent` | `agent_url` + `id` | Trust-based - buyer trusts the agent | + +### Example: Agent-Native Signal + +```json +{ + "signal_id": { + "source": "agent", + "agent_url": "https://luminary-data.com/.well-known/adcp/signals", + "id": "custom_auto_intenders" + }, + "value_type": "binary", + "value": true +} +``` + +### When to Use Each + +**Use `source: "catalog"`** when: +- Signal comes from an external data provider (Pinnacle Data, Meridian Analytics, etc.) +- Authorization verification is important +- You want to reference the canonical signal definition + +**Use `source: "agent"`** when: +- Signal is proprietary to the signals agent +- No external data provider to verify against +- Agent has created custom models or first-party segments + +## Complete Example + +A full signal catalog for an automotive data provider: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/adagents.json", + "contact": { + "name": "Pinnacle Auto Data", + "email": "partnerships@pinnacle-auto-data.com", + "domain": "pinnacle-auto-data.com" + }, + "signals": [ + { + "id": "likely_ev_buyers", + "name": "Likely EV Buyers", + "description": "Consumers modeled as likely to purchase an electric vehicle in the next 12 months based on vehicle registration, financial, and behavioral data", + "value_type": "binary", + "tags": ["automotive", "premium"] + }, + { + "id": "vehicle_ownership", + "name": "Current Vehicle Ownership", + "description": "Current vehicle category owned by the consumer", + "value_type": "categorical", + "allowed_values": ["luxury_ev", "luxury_non_ev", "mid_range", "economy", "none"], + "tags": ["automotive"] + }, + { + "id": "purchase_propensity", + "name": "Auto Purchase Propensity", + "description": "Likelihood score of purchasing any new vehicle in the next 6 months", + "value_type": "numeric", + "range": { "min": 0, "max": 1, "unit": "score" }, + "tags": ["automotive"] + } + ], + "signal_tags": { + "automotive": { + "name": "Automotive Signals", + "description": "Vehicle-related audience segments" + }, + "premium": { + "name": "Premium Signals", + "description": "High-value premium audience segments with enhanced pricing" + } + }, + "authorized_agents": [ + { + "url": "https://luminary-data.com/.well-known/adcp/signals", + "authorized_for": "All Pinnacle automotive signals via Luminary Data", + "authorization_type": "signal_tags", + "signal_tags": ["automotive"] + }, + { + "url": "https://nova-dsp.com/.well-known/adcp/signals", + "authorized_for": "Pinnacle premium signals only", + "authorization_type": "signal_ids", + "signal_ids": ["likely_ev_buyers"] + } + ], + "last_updated": "2025-01-15T10:00:00Z" +} +``` + +## Location data provider example + +A geo/mobility provider's signal catalog uses the same structure but with location-specific signals. Here's the `signals` array for a provider publishing foot traffic and mobility data: + +```json +{ + "signals": [ + { + "id": "store_visitors", + "name": "Store Visitors", + "description": "Consumers who visited a specified retail location in the past 30 days based on opted-in mobile device data", + "value_type": "binary", + "tags": ["geo", "foot_traffic"] + }, + { + "id": "visit_frequency", + "name": "Location Visit Frequency", + "description": "Monthly visit count to a specified location category", + "value_type": "numeric", + "range": { "min": 0, "max": 30, "unit": "visits_per_month" }, + "tags": ["geo", "frequency"] + }, + { + "id": "commute_pattern", + "name": "Commute Pattern", + "description": "Categorized daily commute behavior based on observed travel patterns", + "value_type": "categorical", + "allowed_values": ["urban_transit", "suburban_driver", "remote_worker", "hybrid"], + "tags": ["geo", "behavioral"] + } + ] +} +``` + +Note how the three value types map to different geo concepts: `binary` for yes/no store visitation, `numeric` for visit frequency with a meaningful range, and `categorical` for classified mobility behavior. + +## Identity / demographic provider example + +An identity company's signal catalog publishes consumer segments derived from financial records, surveys, and public data. Note: these are **targeting segments**, not raw data. Credit-derived signals may carry regulatory obligations (FCRA) — consult your compliance team before publishing. + +```json +{ + "signals": [ + { + "id": "household_income", + "name": "Household Income Tier", + "description": "Modeled household income bracket based on financial and demographic indicators", + "value_type": "categorical", + "allowed_values": ["under_50k", "50k_75k", "75k_100k", "100k_150k", "150k_250k", "over_250k"], + "tags": ["demographic", "income"] + }, + { + "id": "life_stage", + "name": "Life Stage", + "description": "Life stage classification derived from demographic and behavioral indicators", + "value_type": "categorical", + "allowed_values": ["young_adult", "early_career", "established_family", "empty_nester", "retired"], + "tags": ["demographic", "life_stage"] + }, + { + "id": "credit_active", + "name": "Active Credit Seeker", + "description": "Consumer has actively applied for new credit products in the past 90 days", + "value_type": "binary", + "tags": ["financial", "in_market", "credit"] + } + ] +} +``` + +Identity companies often also provide cross-device identity graphs, but identity resolution as a service (matching Device A to Person B) is not yet part of the AdCP protocol. See the [signals ecosystem guide](/dist/docs/3.0.13/signals/ecosystem#identity-companies) for more on this boundary. + +## Retail media provider example + +Retailers have first-party purchase data that doubles as high-value targeting signals. A retail media network can publish signals alongside its properties in the same `adagents.json`: + +```json +{ + "signals": [ + { + "id": "category_buyer", + "name": "Category Buyer", + "description": "Purchased in the specified product category within the past 90 days", + "value_type": "categorical", + "allowed_values": ["electronics", "home", "beauty", "grocery", "fashion"], + "tags": ["retail", "purchase"] + }, + { + "id": "purchase_frequency", + "name": "Monthly Purchase Frequency", + "description": "Number of purchases in a product category over the trailing 90 days", + "value_type": "numeric", + "range": { "min": 0, "max": 50, "unit": "purchases" }, + "tags": ["retail", "frequency"] + }, + { + "id": "new_to_brand", + "name": "New to Brand", + "description": "Consumer has no prior purchase history with the specified brand in the trailing 12 months", + "value_type": "binary", + "tags": ["retail", "conquest"] + } + ] +} +``` + +Retail signals are especially valuable because they're deterministic — based on actual purchases, not modeled behavior. See the [signals ecosystem guide](/dist/docs/3.0.13/signals/ecosystem#retail-media-networks) for the dual-role pattern (publisher + data provider). + +## Validation + +Use the [AdAgents.json Builder](https://agenticadvertising.org/adagents/builder) to validate your signal catalog, or validate programmatically: + +```bash +curl -X POST https://adcontextprotocol.org/api/adagents/validate \ + -H "Content-Type: application/json" \ + -d '{"domain": "your-domain.com"}' | jq '.data.validation' +``` + +The validator checks: +- Required fields (`id`, `name`, `value_type` for each signal) +- ID patterns (alphanumeric with underscores/hyphens) +- Tag consistency (tags used in signals should be defined in `signal_tags`) +- Authorization references (signal_ids/signal_tags should reference existing signals/tags) + +## Best Practices + +### 1. Use Descriptive IDs + +```json +// Good +{ "id": "likely_ev_buyers" } +{ "id": "household_income_150k_plus" } + +// Avoid +{ "id": "seg_12345" } +{ "id": "a1b2c3" } +``` + +### 2. Provide Complete Metadata + +Include `description` so buyers understand what each signal represents. + +### 3. Use Tags for Scalability + +As your catalog grows, tags enable efficient authorization without listing every signal ID. + +### 4. Document Value Types Clearly + +For categorical signals, always include `allowed_values`. For numeric signals, include `range` with `unit`. + +### 5. Keep Files Updated + +Update `last_updated` timestamp when signals change. Buyers cache these files - stale data causes authorization failures. + +## Declaring governance metadata + +Signal definitions support two optional fields that enable structural governance matching: `restricted_attributes` and `policy_categories`. When declared, governance agents can match signals against a campaign plan's restrictions deterministically instead of relying on semantic inference from signal names. + +### restricted_attributes + +Declare which GDPR Article 9 special categories of personal data a signal touches. Values: `racial_ethnic_origin`, `political_opinions`, `religious_beliefs`, `trade_union_membership`, `health_data`, `sex_life_sexual_orientation`, `genetic_data`, `biometric_data`. + +```json +{ + "id": "chronic_condition_hh", + "name": "Chronic Condition Households", + "description": "Households with modeled indicators of chronic health conditions", + "value_type": "binary", + "tags": ["health", "demographic"], + "restricted_attributes": ["health_data"] +} +``` + +When a campaign plan declares `restricted_attributes: ["health_data"]`, a governance agent blocks this signal without needing to interpret the description. + +### policy_categories + +Declare which policy categories a signal is sensitive for. Policy categories group related regulatory regimes — `children_directed` covers COPPA, UK AADC, and GDPR Article 8. Values are registry-defined category IDs. + +```json +{ + "id": "kids_cartoon_fans", + "name": "Kids Cartoon Fans", + "description": "Children aged 6-12 who watch animated content", + "value_type": "binary", + "tags": ["entertainment", "children"], + "policy_categories": ["children_directed"] +} +``` + +### Combining both fields + +A signal can declare both when it touches restricted personal data and is relevant to a specific regulatory regime: + +```json +{ + "id": "fertility_intent", + "name": "Fertility Intent", + "description": "Consumers researching fertility treatments", + "value_type": "binary", + "tags": ["health", "life_stage"], + "restricted_attributes": ["health_data"], + "policy_categories": ["pharmaceutical_advertising"] +} +``` + +Without governance metadata, a governance agent must infer sensitivity from signal names — this is fragile and produces false positives. Declared attributes enable deterministic matching. + +### Relationship to the Policy Registry + +Signal definitions declare `policy_categories` and `restricted_attributes` using the same vocabulary as the [Policy Registry](/dist/docs/3.0.13/governance/policy-registry). These fields enable governance agents to match signal metadata against policy entries during campaign validation. + +| Signal field | Registry equivalent | Purpose | +|-------------|-------------------|---------| +| `policy_categories` | `policy_categories` on [policy entries](/dist/docs/3.0.13/governance/policy-registry#policy-category-definitions) | Declares which regulatory regimes the signal touches (e.g., `children_directed`, `health_wellness`) | +| `restricted_attributes` | `restricted_attributes` on [policy categories](/dist/docs/3.0.13/governance/policy-registry#restricted-attribute-definitions) | Declares which GDPR Article 9 special categories the signal touches (e.g., `health_data`, `racial_ethnic_origin`) | + +Values MUST match the canonical definitions in the Policy Registry. See [policy category definitions](/dist/docs/3.0.13/governance/policy-registry#policy-category-definitions) for the full list of valid `policy_categories` values and [restricted attribute definitions](/dist/docs/3.0.13/governance/policy-registry#restricted-attribute-definitions) for valid `restricted_attributes` values. + +## Integration with get_adcp_capabilities + +Signal agents advertise available data providers via `get_adcp_capabilities`: + +```json +{ + "signals": { + "data_provider_domains": ["pinnacle-auto-data.com", "meridian-analytics.com", "apex-segments.com"] + } +} +``` + +This tells buyers which data providers' catalogs the agent can access. + +## Next Steps + +1. **Create your adagents.json** with your signal catalog +2. **Host at** `/.well-known/adagents.json` on your domain +3. **Validate** using the AdAgents.json Builder +4. **Partner with signals agents** who will resell your data +5. **Add agents to authorized_agents** as partnerships are established + +## Related Documentation + +- [Signals Protocol Overview](/dist/docs/3.0.13/signals/overview) - How signals work in AdCP +- [get_signals Task](/dist/docs/3.0.13/signals/tasks/get_signals) - Signal discovery API +- [activate_signal Task](/dist/docs/3.0.13/signals/tasks/activate_signal) - Signal activation API +- [adagents.json Tech Spec](/dist/docs/3.0.13/governance/property/adagents) - Full adagents.json reference (property-focused) diff --git a/dist/docs/3.0.13/signals/ecosystem.mdx b/dist/docs/3.0.13/signals/ecosystem.mdx new file mode 100644 index 0000000000..af8d9875ee --- /dev/null +++ b/dist/docs/3.0.13/signals/ecosystem.mdx @@ -0,0 +1,523 @@ +--- +title: Signals ecosystem +description: "AdCP signals ecosystem: how data providers, retailers, publishers, CDPs, and identity companies expose and activate audience signals through the signals protocol." +"og:title": "AdCP — Signals ecosystem" +--- + +# Signals ecosystem + +The [Signals Protocol](/dist/docs/3.0.13/signals/overview) connects many types of companies. This guide shows how each fits in, what they build, and where to go next. + +## How signals flow + +``` +Data providers ──┐ +Retailers ───────┤ +Publishers ──────┤──→ Signal catalog ──→ Signal agent ──→ Buyer agent ──→ Campaign targeting +CDPs ────────────┤ (adagents.json) (get_signals) (activate) +Identity cos ────┘ +``` + +Every company that owns targetable data can publish a **signal catalog** via `/.well-known/adagents.json`. Signal agents discover these catalogs and make them available to buyers through `get_signals` and `activate_signal`. + +## Find your role + + + + You own audience or behavioral data and want to make it available for ad targeting. + + + You have shopper purchase data and sell both inventory and data. + + + You have contextual and first-party subscriber data alongside your ad inventory. + + + You provide identity resolution, demographic, or financial data. + + + You have foot traffic, geofencing, or mobility data. [See how geo signals work →](#location-and-mobility-providers) + + + You manage brands' first-party data and activate audiences on their behalf. + + + You buy media for clients and have proprietary data assets. + + + You store data and enable clean-room collaboration. + + + +--- + +## Data providers + +**Examples**: Automotive data companies, financial data providers, behavioral data companies + +**Your role**: You own audience segments, propensity models, or behavioral data. You publish a signal catalog so that signal agents can discover and resell your data. + +**What you build**: +1. A signal catalog in your `/.well-known/adagents.json` describing your signals, their value types, and which agents are authorized to resell them +2. Nothing else — signal agents handle discovery and activation on your behalf + +**Signal types you'd publish**: + +```json +{ + "signals": [ + { + "id": "likely_ev_buyers", + "name": "Likely EV Buyers", + "value_type": "binary", + "description": "Consumers modeled as likely to purchase an EV in the next 12 months", + "tags": ["automotive", "purchase_intent"] + }, + { + "id": "vehicle_ownership", + "name": "Vehicle Ownership Category", + "value_type": "categorical", + "allowed_values": ["luxury_ev", "luxury_ice", "midrange", "economy", "truck_suv"], + "tags": ["automotive", "ownership"] + }, + { + "id": "purchase_propensity", + "name": "Auto Purchase Propensity", + "value_type": "numeric", + "range": { "min": 0, "max": 1, "unit": "score" }, + "tags": ["automotive", "purchase_intent"] + } + ] +} +``` + +**Next steps**: +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — complete walkthrough of signal catalogs +- [Signals specification](/dist/docs/3.0.13/signals/specification) — protocol details +- [S3: Signals specialist module](/dist/docs/3.0.13/learning/specialist/signals) — hands-on lab with sandbox signal agent + +--- + +## Location and mobility providers + +**Examples**: Foot traffic analytics companies, mobility data platforms, geofenced audience providers + +**Your role**: You publish geographic and behavioral signals derived from opted-in mobile device data — foot traffic patterns, trade areas, dwell time, and commute behavior. These signals let buyers target audiences based on where people go in the physical world. + +**What you build**: +1. A signal catalog in your `/.well-known/adagents.json` describing your geo signals and their value types +2. Nothing else — signal agents handle discovery and activation on your behalf + +**Example signal catalog**: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/adagents.json", + "contact": { + "name": "Meridian Geo", + "email": "partnerships@meridiangeo.example", + "domain": "meridiangeo.example" + }, + "signals": [ + { + "id": "competitor_visitors", + "name": "Competitor Store Visitors", + "value_type": "binary", + "description": "Consumers who visited a competitor retail location in the past 30 days based on verified foot traffic data", + "tags": ["geo", "retail", "conquest"] + }, + { + "id": "trade_area_residents", + "name": "Trade Area Residents", + "value_type": "binary", + "description": "Consumers whose primary residence is within a specified trade area defined by drive time or radius", + "tags": ["geo", "proximity"] + }, + { + "id": "visit_frequency", + "name": "Location Visit Frequency", + "value_type": "numeric", + "range": { "min": 0, "max": 30 }, + "description": "Monthly visit count to a specified location category (QSR, grocery, gym, auto dealer)", + "tags": ["geo", "frequency"] + }, + { + "id": "dwell_time", + "name": "Average Dwell Time", + "value_type": "numeric", + "range": { "min": 0, "max": 120, "unit": "minutes" }, + "description": "Average minutes spent on-site, distinguishing drive-bys from intentional visits", + "tags": ["geo", "behavioral", "dwell"] + }, + { + "id": "daypart_visitation", + "name": "Day-Part Visitation", + "value_type": "categorical", + "allowed_values": ["morning_commute", "midday", "evening_commute", "weekend_daytime", "weekend_evening"], + "description": "When consumers typically visit a specified venue type", + "tags": ["geo", "temporal"] + } + ], + "signal_tags": { + "geo": { + "name": "Geographic Signals", + "description": "Location-derived audience segments from opted-in mobile data" + } + }, + "authorized_agents": [ + { + "url": "https://signals-agent.example.com", + "authorized_for": "All geo signals", + "authorization_type": "signal_tags", + "signal_tags": ["geo"] + } + ] +} +``` + +**Activating a geofenced audience**: + +```json +{ + "tool": "activate_signal", + "arguments": { + "signal_agent_segment_id": "meridian_trade_area_residents", + "pricing_option_id": "po_meridian_trade_cpm", + "destinations": [ + { + "type": "platform", + "platform": "nova-dsp", + "account": "agency-seat-789" + } + ] + } +} +``` + + +AdCP supports location-derived **audience segments** — groups of people who visited a place, live in a trade area, or exhibit a commute pattern. Real-time geofencing triggers (push a message when someone enters a zone) are not yet part of the protocol. See the [roadmap](/dist/docs/3.0.13/reference/roadmap) for planned extensions. + + +**Next steps**: +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — complete walkthrough of signal catalogs +- [Signals specification](/dist/docs/3.0.13/signals/specification) — protocol details +- [S3: Signals specialist module](/dist/docs/3.0.13/learning/specialist/signals) — hands-on lab with sandbox signal agent + +--- + +## Retail media networks + +**Examples**: Marketplace advertising platforms, grocery delivery ad networks + +**Your dual role**: You're both a **publisher** (selling ad inventory on your marketplace) and a **data provider** (your shopper purchase data is valuable for targeting on other platforms). + +### As a publisher + +You sell sponsored products and display inventory. This uses the [Media Buy Protocol](/dist/docs/3.0.13/media-buy/index) — declare your properties in `adagents.json`, build a sales agent, handle `get_products` and `create_media_buy`. + +### As a data provider + +Your first-party purchase data (category buyers, loyalty tiers, basket value, new-to-brand) is valuable beyond your own inventory. You can publish these as signals in the same `adagents.json` file alongside your properties. + +**Combined adagents.json** (properties for inventory + signals for data): + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/adagents.json", + "contact": { + "name": "Acme Marketplace", + "email": "partnerships@acme-marketplace.example", + "domain": "acme-marketplace.example" + }, + "properties": [ + { + "property_id": "marketplace_web", + "property_type": "website", + "name": "Acme Marketplace", + "identifiers": [ + {"type": "domain", "value": "acme-marketplace.example"} + ], + "supported_channels": ["retail_media", "display"] + } + ], + "signals": [ + { + "id": "category_buyer", + "name": "Category Buyer", + "value_type": "categorical", + "allowed_values": ["electronics", "home", "beauty", "grocery", "fashion"], + "description": "Purchased in category in past 90 days", + "tags": ["retail", "purchase"] + }, + { + "id": "loyalty_tier", + "name": "Loyalty Program Tier", + "value_type": "categorical", + "allowed_values": ["platinum", "gold", "silver", "bronze"], + "tags": ["retail", "loyalty"] + }, + { + "id": "new_to_brand", + "name": "New to Brand", + "value_type": "binary", + "description": "Never purchased from specified brand on marketplace", + "tags": ["retail", "conquest"] + } + ], + "authorized_agents": [ + { + "url": "https://signals-agent.example.com", + "authorized_for": "All retail signals", + "authorization_type": "signal_tags", + "signal_tags": ["retail"] + } + ] +} +``` + +**Key insight**: Your closed-loop purchase data is often the most valuable signal in the ecosystem because it's deterministic. While behavioral models predict intent, you have actual transaction proof. + +**Closed-loop measurement**: Your purchase data also enables attribution. When a campaign targets your shopper signals and the consumer buys the advertised product on your marketplace, you can measure that conversion directly. Use [`sync_event_sources`](/dist/docs/3.0.13/media-buy/task-reference/sync_event_sources) to register your purchase event feed with buyer agents, then [`log_event`](/dist/docs/3.0.13/media-buy/task-reference/log_event) to report conversions. This closes the loop from targeting to measurement without relying on third-party attribution — a significant advantage for retail media. + +**Next steps**: +- [Commerce media guide](/dist/docs/3.0.13/media-buy/commerce-media) — maps retail concepts to AdCP +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — how to publish your signal catalog +- [Conversion tracking](/dist/docs/3.0.13/media-buy/conversion-tracking/index) — closed-loop measurement with `sync_event_sources` and `log_event` +- [Seller integration guide](/dist/docs/3.0.13/building/operating/seller-integration) — building your sales agent + +--- + +## Publishers + +**Examples**: News publishers, streaming platforms, content networks + +**Your role**: You have contextual signals (content category, article sentiment) and first-party subscriber data (engagement level, subscription tenure). These complement your ad inventory. + +**What you already have**: Properties in `adagents.json` and a sales agent for the Media Buy Protocol. + +**What you can add**: Signal definitions in the same `adagents.json`, turning your contextual intelligence and subscriber data into targetable signals available across the ecosystem. + +**Publisher signal types**: + +| Signal | Value type | Description | +|--------|-----------|-------------| +| Content category | Categorical | IAB taxonomy classification of page content | +| Article sentiment | Categorical | Positive, neutral, negative, mixed | +| Engaged reader | Binary | High-attention subscribers (5+ articles/week) | +| Subscriber tenure | Numeric | Months of active subscription | + +**Why this matters**: Your contextual signals are increasingly valuable as third-party cookies decline. An advertiser buying CTV inventory from one publisher can target using your contextual intelligence from another — if you publish it as a signal. + +**Next steps**: +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — add signals to your existing adagents.json +- [Publisher/seller track](/dist/docs/3.0.13/learning/tracks/publisher) — build your sales agent (if you haven't already) +- [S3: Signals specialist module](/dist/docs/3.0.13/learning/specialist/signals) — understand the buyer's perspective + +--- + +## Identity companies + +**Examples**: Cross-device identity providers, demographic data companies, credit-derived data companies + +**Your role**: You provide identity resolution (linking devices to people to households) and consumer data derived from financial records, public data, or surveys. + +**What fits in AdCP today**: Your **consumer segments** — household income tiers, life stages, credit activity, cross-device reach — map directly to signal value types: + +```json +{ + "signals": [ + { + "id": "household_income", + "name": "Household Income Tier", + "value_type": "categorical", + "allowed_values": ["under_50k", "50k_75k", "75k_100k", "100k_150k", "150k_250k", "over_250k"], + "tags": ["demographic", "income"] + }, + { + "id": "life_stage", + "name": "Life Stage", + "value_type": "categorical", + "allowed_values": ["young_adult", "early_career", "established_family", "empty_nester", "retired"], + "tags": ["demographic", "life_stage"] + }, + { + "id": "household_composition", + "name": "Household Composition", + "value_type": "categorical", + "allowed_values": ["single", "couple_no_children", "family_young_children", "family_teens", "multigenerational"], + "tags": ["demographic", "household"] + }, + { + "id": "cross_device_reach", + "name": "Cross-Device Household Reach", + "value_type": "numeric", + "range": { "min": 1, "max": 12 }, + "description": "Identified devices linked to household via deterministic identity graph", + "tags": ["identity", "cross_device"] + } + ] +} +``` + +**What doesn't fit (yet)**: Your core identity resolution service — matching Device A to Person B — is infrastructure that enhances other signals, not a signal itself. AdCP doesn't yet have a protocol for identity resolution as a service. This is on the [roadmap](/dist/docs/3.0.13/reference/roadmap). + +**Where you add value today**: +1. **Publish demographic and financial segments** as signals in your catalog +2. **Enhance other providers' signals** with cross-device reach (your identity graph makes their binary signals addressable across more devices) +3. **Partner with signal agents** who can combine your identity data with other providers' behavioral data + + +**Data governance for credit-derived signals**: Signals derived from credit bureau data may carry regulatory obligations under FCRA and similar frameworks. AdCP publishes these as targeting segments (income tiers, credit activity), not raw financial data — but your compliance team should review which segments are permissible for advertising use cases. The `activate_signal` deactivation mechanism supports compliance workflows when consent is withdrawn or regulatory requirements change. + + +**Next steps**: +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — publish your signal catalog +- [Platform/intermediary track](/dist/docs/3.0.13/learning/tracks/platform) — if you're building infrastructure that connects data to platforms + +--- + +## Customer data platforms + +**Examples**: Customer data platforms, audience management platforms, marketing data platforms + +**Your role**: Brands store their first-party data in your platform. You help them build audience segments and activate those segments for ad targeting. The brand owns the data — you're the infrastructure. + +**The ownership question**: In the standard data provider model, the provider publishes signals under their own domain. For CDPs, the **brand** owns the data but the **CDP** operates the infrastructure. Two approaches: + +### Approach 1: CDP as signal agent + +The CDP operates a signal agent that serves brand-specific custom segments. Each brand's segments are scoped to their account: + +```json +{ + "tool": "get_signals", + "arguments": { + "signal_spec": "High lifetime value customers for retargeting", + "account": { + "brand": { "domain": "acme-brand.example" } + } + } +} +``` + +The signal agent returns only segments authorized for that brand's account. This maps naturally to how CDPs already work — brand-scoped audience management. + +### Approach 2: Brand publishes catalog, CDP hosts agent + +The brand lists their custom signals in their own `adagents.json` (at `acme-brand.example/.well-known/adagents.json`) with the CDP's signal agent as an authorized agent. This gives the brand transparency and control over what's published. + +**CDP signal types**: + +| Signal | Value type | Use case | +|--------|-----------|----------| +| High LTV customer | Binary | Retention and upsell campaigns | +| Cart abandoner | Binary | Real-time retargeting | +| Engagement score | Numeric (0-100) | Prioritize high-intent prospects | +| Churn risk | Categorical | Win-back campaigns | + +**Key concern — privacy**: First-party data activation must respect consent. The CDP is responsible for ensuring that only consented segments are published. The `activate_signal` task supports `deactivate` action for compliance (removing segments from platforms when consent is withdrawn or campaigns end). + +**Next steps**: +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — catalog structure +- [Platform/intermediary track](/dist/docs/3.0.13/learning/tracks/platform) — building MCP-based infrastructure +- [Signals specification](/dist/docs/3.0.13/signals/specification) — protocol conformance requirements + +--- + +## Agencies with data assets + +**Examples**: Agency holding companies with data divisions, independent agencies with proprietary audience platforms + +**Your dual role**: You **consume** signals for client campaigns (buyer side) and **provide** proprietary signals from your data assets (provider side). + +### As a signal consumer + +Your buying agents use `get_signals` to find targeting data for client campaigns. You work across multiple brands, each with their own account: + +```json +{ + "tool": "get_signals", + "arguments": { + "signal_spec": "In-market luxury auto intenders", + "account": { + "brand": { "domain": "client-brand.example" } + }, + "destinations": [ + { "type": "platform", "platform": "the-trade-desk", "account": "agency-ttd-seat" } + ] + } +} +``` + +### As a signal provider + +Your data division's proprietary signals (identity graph, purchase panels, custom models) can be published via `adagents.json` and made available through signal agents — either your own or third-party. + +**Multi-brand management**: Each client brand has its own account context. Signals activated for one brand are not visible to others. Your agency seat on DSP platforms may be shared, but signal activations are brand-scoped. + +**Next steps**: +- [Buyer/brand track](/dist/docs/3.0.13/learning/tracks/buyer) — media buying with AdCP +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — publish your proprietary signals +- [S3: Signals specialist module](/dist/docs/3.0.13/learning/specialist/signals) — hands-on signal discovery and activation + +--- + +## Data warehouses and clean rooms + +**Examples**: Cloud data platforms with advertising data products, data collaboration platforms + +**Your role**: You sit between data (brands, providers, retailers) and activation (DSPs, sales agents). The question is how signals flow to and from your platform. + +### Activating signals into your platform + +A buyer or agency wants to match a signal provider's audience against data that lives in your warehouse — without moving either party's raw data. In AdCP terms, your platform is a **destination** in `activate_signal`: + +```json +{ + "tool": "activate_signal", + "arguments": { + "signal_agent_segment_id": "trident_likely_ev_buyers", + "pricing_option_id": "po_trident_ev_cpm", + "destinations": [ + { + "type": "platform", + "platform": "apex-data-cloud", + "account": "brand-clean-room-456" + } + ] + } +} +``` + +The signal agent pushes segment membership into your clean room. The brand's first-party data stays in place. Overlap analysis, lookalike modeling, or measurement happens inside your environment. + +### Activating signals from your platform + +Data that lives in your warehouse — a retailer's purchase data, a brand's CRM segments, a provider's behavioral models — can be published as signals. The signal agent queries your platform's APIs to serve `get_signals` requests. The underlying data never leaves the warehouse; only targeting keys (segment IDs, activation keys) flow to DSPs. + +A retailer whose shopper data lives in your platform would: +1. Publish a signal catalog via `adagents.json` describing their signals +2. Partner with a signal agent that has API access to your platform +3. The signal agent handles `get_signals` (checking availability) and `activate_signal` (pushing targeting keys to DSPs) + +### What you'd build + +To participate as a destination platform, you'd implement the receiving side: accept segment activations from signal agents, match against data in your environment, and return activation keys. This is analogous to how DSPs accept audience segments today. + +**Next steps**: +- [Platform/intermediary track](/dist/docs/3.0.13/learning/tracks/platform) — MCP server architecture +- [Signals specification](/dist/docs/3.0.13/signals/specification) — destination and deployment model +- [Industry landscape](/dist/docs/3.0.13/building/concepts/industry-landscape) — how AdCP fits alongside other standards +- [Working group](/dist/docs/3.0.13/community/working-group) — help shape clean room integration patterns + +--- + +## Try it hands-on + +The training agent at `https://test-agent.adcontextprotocol.org/mcp` includes sandbox signal providers covering automotive data, geo/mobility, retail purchase data, identity/demographics, publisher contextual signals, and CDP audiences. + +Use `get_signals` to discover signals and `activate_signal` to activate them — all in sandbox mode with no real data or cost. + + + Tell Addie: "I'd like to start the signals specialist module" — or just describe your role and ask how you fit into the signals ecosystem. + diff --git a/dist/docs/3.0.13/signals/key-concepts.mdx b/dist/docs/3.0.13/signals/key-concepts.mdx new file mode 100644 index 0000000000..5d75e004a9 --- /dev/null +++ b/dist/docs/3.0.13/signals/key-concepts.mdx @@ -0,0 +1,149 @@ +--- +title: Key concepts +description: "AdCP signal types (binary, categorical, numeric), signal sources (catalog vs agent), discovery via get_signals, activation via activate_signal, and authorization through adagents.json." +"og:title": "AdCP — Signals key concepts" +--- + +# Key concepts + +The Signals Protocol enables AI agents to discover, activate, and manage data signals for advertising campaigns. Signals represent targetable audiences, contextual categories, geographic regions, and other data attributes. + +## What are signals? + +Signals are data segments used for targeting or measurement in advertising campaigns: + +- **Audience signals**: User segments based on demographics, interests, or behaviors +- **Contextual signals**: Content categories or page contexts +- **Geographic signals**: Location-based targeting data +- **Temporal signals**: Time-based targeting patterns +- **Multi-dimensional signals**: Combined or custom signal types + +## Signal value types + +Every signal has a `value_type` that determines how buyers construct targeting expressions: + +### Binary + +User either matches or doesn't. The most common type. + +```json +{ + "id": "likely_ev_buyers", + "name": "Likely EV Buyers", + "value_type": "binary", + "tags": ["automotive", "purchase_intent"] +} +``` + +**Targeting**: Include or exclude users matching this signal. + +### Categorical + +User has one of several possible values. + +```json +{ + "id": "vehicle_ownership", + "name": "Current Vehicle Ownership", + "value_type": "categorical", + "allowed_values": ["luxury_ev", "luxury_non_ev", "mid_range", "economy", "none"] +} +``` + +**Targeting**: Target users with specific values (e.g., "users who own a luxury EV or luxury non-EV"). + +### Numeric + +User has a score or measurement within a range. + +```json +{ + "id": "purchase_propensity", + "name": "Auto Purchase Propensity", + "value_type": "numeric", + "range": { "min": 0, "max": 1, "unit": "score" } +} +``` + +**Targeting**: Target users within a value range (e.g., "propensity score > 0.7"). + +## Signal sources + +Signal IDs use `source` as a discriminator: + +| Source | Fields | Verification | +|--------|--------|--------------| +| `catalog` | `data_provider_domain` + `id` | Verifiable via data provider's adagents.json | +| `agent` | `agent_url` + `id` | Trust-based — buyer trusts the agent | + +**Catalog signals** come from external data providers who publish their offerings at `/.well-known/adagents.json`. Buyers can independently verify that a signal agent is authorized to resell them. + +**Agent-native signals** are proprietary to the signal agent — custom models, first-party data, or composite segments the agent builds from multiple sources. + +## The two tasks + +| Task | Purpose | +|------|---------| +| [`get_signals`](/dist/docs/3.0.13/signals/tasks/get_signals) | Discover signals matching campaign criteria | +| [`activate_signal`](/dist/docs/3.0.13/signals/tasks/activate_signal) | Activate a signal for use in campaigns | + +### Discovery with get_signals + +Buyers describe what they need in natural language. The signal agent searches across all its data providers' catalogs and its own proprietary signals: + +```json +{ + "tool": "get_signals", + "arguments": { + "signal_spec": "In-market auto buyers with high purchase propensity" + } +} +``` + +The response includes matching signals with pricing, size estimates, and value type metadata — everything a buyer agent needs to make a targeting decision. + +### Activation with activate_signal + +Once a buyer selects a signal, they activate it on their DSP or data platform: + +```json +{ + "tool": "activate_signal", + "arguments": { + "signal_agent_segment_id": "trident_likely_ev_buyers", + "pricing_option_id": "po_trident_ev_cpm", + "destinations": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-seat-123" + } + ] + } +} +``` + +The signal agent pushes segment membership to the specified platform. The buyer's campaign can then target against it using the platform's standard tools. + +## Agent integration + +The Signals Protocol operates within the broader [AdCP ecosystem](/dist/docs/3.0.13/intro#the-adcp-ecosystem-layers). Signal agents integrate directly with decisioning platforms (DSPs, orchestration platforms), eliminating intermediary reporting and usage tracking. Signal agents advertise their available data providers via [`get_adcp_capabilities`](/dist/docs/3.0.13/protocol/get_adcp_capabilities). + +Once signals are activated on a platform, all usage reporting, billing, and campaign metrics are handled directly by that platform. + +## Authorization and trust + +Data providers control who can resell their signals via the `authorized_agents` array in their `adagents.json`. Two patterns: + +- **Signal IDs**: Authorize specific signals by ID — fine-grained control +- **Signal tags**: Authorize all signals with certain tags — scales as catalogs grow + +Buyers can verify authorization by fetching the data provider's `adagents.json` and checking whether the signal agent appears in `authorized_agents`. + +## Go deeper + +- [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — how to publish a signal catalog +- [Signals ecosystem](/dist/docs/3.0.13/signals/ecosystem) — how each type of company participates +- [Protocol specification](/dist/docs/3.0.13/signals/specification) — formal conformance requirements +- [get_signals task reference](/dist/docs/3.0.13/signals/tasks/get_signals) — discovery API details +- [activate_signal task reference](/dist/docs/3.0.13/signals/tasks/activate_signal) — activation API details diff --git a/dist/docs/3.0.13/signals/overview.mdx b/dist/docs/3.0.13/signals/overview.mdx new file mode 100644 index 0000000000..a0755a37f9 --- /dev/null +++ b/dist/docs/3.0.13/signals/overview.mdx @@ -0,0 +1,212 @@ +--- +title: Signals protocol +sidebarTitle: Overview +"og:image": /images/walkthrough/signals-01-planner-brief.png +"og:title": "AdCP — Signals protocol" +description: "Follow a media buyer from campaign brief to signal activation across automotive, geo, and retail data — a visual walkthrough of the AdCP signals workflow." +--- + +Sam stands at a whiteboard sketching a targeting plan, surrounded by floating holographic data icons — audiences, locations, purchase behavior + +Sam is a senior media buyer at Pinnacle Agency. His client, Nova Motors, is launching the Volta EV — their first electric vehicle. The brief: reach in-market auto buyers with high purchase propensity, near dealerships, and focus on consumers who haven't bought a Nova vehicle before. + +Without AdCP, Sam would be emailing three data providers for segment availability, waiting for IO sign-offs, then uploading CSV segment files to two separate DSP platforms — a process that takes days and breaks every time a provider updates their taxonomy. With AdCP, his agency platform handles it in minutes. + +This walkthrough follows Sam from brief to live targeting. + +## Step 1: Describe what you need + +Sam starts with what he wants to accomplish, not a segment taxonomy. + +Sam types a search on his laptop as three data streams flow outward to location, retail, and audience data provider icons — one query, three sources + +Sam's agency platform translates the brief into a `get_signals` call. No need to know which providers have what — the signal agent searches across all of them: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-request.json", + "signal_spec": "In-market EV buyers with high purchase propensity, near auto dealerships" +} +``` + +The signal agent searches catalogs from every authorized data provider — automotive, geo/mobility, retail, identity — and returns what matches. + + + +| What Sam says | What the protocol calls it | +|---|---| +| Target audience | Signal (from `get_signals`) | +| Segment taxonomy | Signal catalog (`adagents.json`) | +| Data provider | Signal source (`data_provider_domain`) | +| Activate on my DSP | `activate_signal` with destination | +| Audience size | `coverage_percentage` in signal response | +| Data cost | `pricing_options` in signal response | + + + +## Step 2: Review what comes back + +Sam reviews signal results on a wall screen, arms crossed — three groups of data cards with different colored accents showing location, retail, and audience data + +The signal agent returns matches from multiple providers. Each signal has a value type, pricing, and coverage estimate: + +```mermaid +sequenceDiagram + participant Platform as Sam's Agency Platform + participant Agent as Signal Agent + participant Auto as Trident Auto Data + participant Geo as Meridian Geo + participant Retail as ShopGrid + + Platform->>Agent: get_signals ("in-market EV buyers near dealerships") + + par Search catalogs + Agent->>Auto: Search catalog + Auto-->>Agent: likely_ev_buyers (binary), purchase_propensity (numeric) + Agent->>Geo: Search catalog + Geo-->>Agent: competitor_visitors (binary), trade_area_residents (binary) + Agent->>Retail: Search catalog + Retail-->>Agent: category_buyer (categorical), new_to_brand (binary) + end + + Agent-->>Platform: 8 matching signals with pricing +``` + +Sam sees signals from three providers he didn't have to find or negotiate with individually: + +- **Trident Auto Data** — `purchase_propensity` (numeric, 0-1 score). CPM: \$1.50. `likely_ev_buyers` (binary). CPM: \$2.50. +- **Meridian Geo** — `competitor_visitors` (binary, people who visited competing dealerships). CPM: \$2.00. +- **ShopGrid** — `new_to_brand` (binary). CPM: \$3.50. `category_buyer` (categorical: electronics, automotive, home). CPM: \$3.00. + +Sam notices that `purchase_propensity` is numeric — his agent can set a threshold (score > 0.7) to focus budget on high-intent prospects rather than a blunt include/exclude. He also sees that `category_buyer` is categorical, so he can target "automotive" specifically without paying for the full ShopGrid audience. The `competitor_visitors` signal catches his eye — it comes from Meridian Geo, a data company founded by Kai Lindström to make location and behavioral data accessible through open protocols. Sam picks three signals: `purchase_propensity` for intent scoring, Meridian Geo's `competitor_visitors` for conquest targeting near rival dealerships, and `new_to_brand` to reach households that haven't bought a Nova before. + +## Step 3: Verify and select + +Before activating third-party data, Pinnacle Agency requires verification. Sam's platform fetches the data provider's catalog directly: + +``` +https://shopgrid.example/.well-known/adagents.json +``` + +And confirms: +1. The `new_to_brand` signal exists in ShopGrid's catalog +2. The signal agent is listed in `authorized_agents` +3. The authorization covers retail signals (via `signal_tags: ["retail"]`) + +If the authorization check had failed — say ShopGrid had revoked the agent's access — Sam would see the signal flagged before spending a dollar on it. This independent verification means buyers don't have to take the signal agent's word for data provenance. + +## Step 4: Activate on your platforms + +Split scene — Sam activates signal segments from his tablet below, data streams flow up to platforms, while Kai monitors activation metrics on his laptop above + +Sam activates his three signals on two DSPs — Nova DSP for programmatic display (broad reach, lower CPMs) and StreamHaus for premium CTV inventory (household-level targeting for the brand spot): + +```mermaid +sequenceDiagram + participant Platform as Agency Platform + participant Agent as Signal Agent + participant DSP1 as Nova DSP (display) + participant DSP2 as StreamHaus (CTV) + + par Activate signals + Platform->>Agent: activate_signal (purchase_propensity → Nova DSP) + Agent->>DSP1: Push segment + DSP1-->>Agent: deployed + + Platform->>Agent: activate_signal (competitor_visitors → Nova DSP) + Agent->>DSP1: Push segment + DSP1-->>Agent: deployed + + Platform->>Agent: activate_signal (new_to_brand → StreamHaus) + Agent->>DSP2: Push segment + DSP2-->>Agent: deployed + end + + Agent-->>Platform: 3 signals active, deployment IDs returned +``` + +Each activation call specifies the destination platform and account: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json", + "idempotency_key": "c3d4e5f6-a7b8-4901-c234-901234567890", + "signal_agent_segment_id": "shopgrid_new_to_brand", + "pricing_option_id": "po_shopgrid_retail_cpm", + "destinations": [ + { + "type": "platform", + "platform": "streamhaus", + "account": "agency-ctv-seat-456" + } + ] +} +``` + +The signal agent pushes segment membership to each platform. Sam gets back deployment IDs he can reference when building media buys. + +### Buying through a sales agent + +Sam also wants to run a sponsored article campaign through Wonderstruck, a premium publisher with its own sales agent. Instead of activating the signal on a specific DSP, Sam activates it on the sales agent directly — Wonderstruck handles its own DSP coordination: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json", + "idempotency_key": "d4e5f6a7-b8c9-4012-d345-012345678901", + "signal_agent_segment_id": "shopgrid_new_to_brand", + "pricing_option_id": "po_shopgrid_retail_cpm", + "destinations": [ + { + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.example" + } + ] +} +``` + +The sales agent records the activation internally. When Sam later calls `create_media_buy` through Wonderstruck, the signal-based targeting is already in place — Sam doesn't need to know which DSP Wonderstruck uses behind the scenes. + +## Step 5: Build the campaign + +Sam views three targeting layers stacking on a large screen — location, audience, and purchase data overlap and glow where they intersect + +Now the signals are live on both platforms. Sam's agent builds media buys using [`create_media_buy`](/dist/docs/3.0.13/media-buy/task-reference/create_media_buy). The activated signals are already available as targeting segments on each DSP — the media buy references the products discovered via [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products), and the DSP applies the signal-based targeting automatically. + +- **Display (Nova DSP)**: Sam selects a display product that targets `purchase_propensity > 0.7` AND `competitor_visitors = true`. This reaches high-intent auto buyers who've been visiting competing dealerships. +- **CTV (StreamHaus)**: Sam selects a CTV product targeting `new_to_brand = true`. This reaches households that haven't purchased a Nova vehicle with a brand awareness spot. + +The key point: signals and media buys are separate concerns. The Signals Protocol gets data onto platforms. The [Media Buy Protocol](/dist/docs/3.0.13/media-buy/index) gets campaigns running on those platforms. They compose together — Sam didn't need a custom integration between his signal providers and his DSPs. + +## Step 6: Manage and measure + +The campaign runs. Two weeks in, display CPAs are running 40% above target while CTV is pacing well. Sam reallocates: he deactivates the geo signal on Nova DSP to reduce display data costs: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json", + "idempotency_key": "e5f6a7b8-c9d0-4123-e456-123456789012", + "signal_agent_segment_id": "meridian_competitor_visitors", + "action": "deactivate", + "destinations": [ + { + "type": "platform", + "platform": "nova-dsp", + "account": "agency-display-seat-123" + } + ] +} +``` + +Deactivation removes the segment from the platform. Billing stops. The CTV signals stay active — each activation is independent. + +Sam and Kai stand together in front of a large display showing the signals marketplace — data providers flowing through a hub to buyers, collaborating across the ecosystem + +Every step uses a standard AdCP task. Sam didn't need to know which data providers exist, negotiate individual contracts, or build custom integrations per DSP. Data providers like Kai Lindström's Meridian Geo publish their catalogs once, and the signal agent handles discovery across all of them. Each platform handles targeting with its standard tools once segments arrive. + +## Go deeper + +- **Key concepts**: [Signal types, sources, and authorization](/dist/docs/3.0.13/signals/key-concepts) — the building blocks behind this walkthrough +- **Ecosystem**: [Who participates and how](/dist/docs/3.0.13/signals/ecosystem) — data providers, retailers, publishers, CDPs, agencies, identity companies +- **Publish your data**: [Data provider guide](/dist/docs/3.0.13/signals/data-providers) — how to create a signal catalog +- **Protocol spec**: [Signals specification](/dist/docs/3.0.13/signals/specification) — formal requirements and conformance +- **Get certified**: The [Signals specialist module](/dist/docs/3.0.13/learning/specialist/signals) teaches signal discovery and activation through interactive labs with a sandbox signal agent diff --git a/dist/docs/3.0.13/signals/specification.mdx b/dist/docs/3.0.13/signals/specification.mdx new file mode 100644 index 0000000000..29b5823523 --- /dev/null +++ b/dist/docs/3.0.13/signals/specification.mdx @@ -0,0 +1,202 @@ +--- +title: Signals Specification +description: "Formal AdCP signals protocol specification. Transport requirements, get_signals and activate_signal task schemas, conformance criteria, error handling, activation key security, and RFC 2119 requirements." +"og:title": "AdCP — Signals Specification" +sidebarTitle: Specification +--- + +**Status**: Request for Comments +**Last Updated**: January 25, 2026 + +This document defines the Signals Protocol specification. 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 Signals Protocol defines a standard interface for AI-powered signal discovery, activation, and management systems. This protocol enables AI assistants to help marketers discover, activate, and manage data signals (audiences, contextual, geographical, temporal, and multi-dimensional data) through natural language interactions. + +## Protocol Overview + +The Signals Protocol provides: + +- Natural language signal discovery based on marketing objectives +- Multi-platform signal discovery in a single request +- Signal activation for specific platforms and accounts +- Transparent pricing with CPM and revenue share models +- Signal size reporting with unit types (individuals, devices, households) + +## Transport Requirements + +Signal 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 | + +Signal agents SHOULD support MCP as the preferred transport. + +Signal agents MUST declare Signals Protocol support via `get_adcp_capabilities`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.0.13/protocol/get-adcp-capabilities-response.json", + "adcp": { + "major_versions": [2], + "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } + }, + "supported_protocols": ["signals"] +} +``` + +## Core Concepts + +### Request Roles + +Every signal request involves two roles: + +- **Orchestrator**: The platform making the API request (e.g., a buyer agent or AI assistant) +- **Account**: The commercial relationship on whose behalf the request is made. See [Accounts Protocol](/dist/docs/3.0.13/accounts/overview). + +### Signal Agent Types + +**Private Signal Agents** — owned by a single account with exclusive access: +- Signal agents MUST return `REFERENCE_NOT_FOUND` for unauthorized accounts — the + same response as "agent does not exist." Distinguishing "exists but unauthorized" + from "does not exist" would enable cross-tenant enumeration of private agents. + See the uniform-response MUST in + [error-handling.mdx](/dist/docs/3.0.13/building/by-layer/L3/error-handling) for the full + set of observable channels that MUST match on both paths. +- Signal agents MUST NOT expose private signals across accounts + +**Marketplace Signal Agents** — license signal data to multiple accounts: +- Signal agents MUST support public catalog access without account registration +- Signal agents SHOULD support personalized catalogs for registered accounts + +### Identifiers + +- **`signal_agent_segment_id`**: Unique identifier for a signal. Signal agents MUST return this for each signal. Orchestrators MUST use this in `activate_signal` requests. + +- **`activation_key`**: Key for campaign targeting. Signal agents MUST return this when `is_live: true` AND the caller has access to the deployment. Orchestrators MUST use this for targeting (not `signal_agent_segment_id`). + +### Governance metadata + +Signal definitions MAY include `restricted_attributes` and `policy_categories` fields to enable structural governance matching. Data providers SHOULD declare these so governance agents can deterministically evaluate compliance rather than inferring sensitivity from signal names. + +- **`restricted_attributes`**: Array of GDPR Article 9 special category values this signal touches. Governance agents SHOULD prefer declared attributes over semantic inference. +- **`policy_categories`**: Array of policy category IDs this signal is sensitive for. Governance agents match these against a plan's `policy_categories` to flag sensitive data usage. + +See [Declaring governance metadata](/dist/docs/3.0.13/signals/data-providers#declaring-governance-metadata) for implementation details. + +## Tasks + +The Signals Protocol defines two tasks. See task reference pages for complete request/response schemas and examples. + +### get_signals + +**Schema**: [`get-signals-request.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-request.json) / [`get-signals-response.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-response.json) + +**Reference**: [`get_signals` task](/dist/docs/3.0.13/signals/tasks/get_signals) + +Discover signals matching campaign criteria. + +**Requirements:** +- Orchestrators MUST include `signal_spec` or `signal_ids` +- Signal agents MUST return all required fields per response schema +- Signal agents MUST include `activation_key` when `is_live: true` AND caller has deployment access + +### activate_signal + +**Schema**: [`activate-signal-request.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json) / [`activate-signal-response.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-response.json) + +**Reference**: [`activate_signal` task](/dist/docs/3.0.13/signals/tasks/activate_signal) + +Activate a signal for use on a decisioning platform. + +**Requirements:** +- Orchestrators MUST include `signal_agent_segment_id` and `destinations` +- On success, signal agents MUST return a `deployments` array with `is_live` for each deployment +- Signal agents MUST return `activation_key` when `is_live: true` AND caller has deployment access +- On failure, signal agents MUST return an `errors` array (with no `deployments` array) + +## Error Handling + +Signal agents MUST return errors using the [standard AdCP error schema](/dist/docs/3.0.13/building/by-layer/L3/error-handling). + +Signal agents MUST use Signals Protocol error codes as defined in the [Error Handling Reference](/dist/docs/3.0.13/building/by-layer/L3/error-handling). + +## Security Considerations + +### Transport Security + +All Signals Protocol communications MUST use HTTPS with TLS 1.2 or higher. + +### Authentication + +- Orchestrators MUST authenticate with signal agents using valid credentials +- Signal agents MUST validate credentials before processing requests +- Signal agents SHOULD use account context to determine catalog access level + +### Activation Key Security + +- Signal agents MUST only return `activation_key` to authenticated callers with deployment access +- Signal agents MUST NOT return activation keys for deployments the caller cannot access + +### Data Minimization + +- Signal agents MUST NOT return signals the authenticated agent or account is not authorized to access + +## Conformance + +### Signal Agent Conformance + +A conformant Signals Protocol agent MUST: + +1. Support at least one specified transport (MCP or A2A) +2. Implement `get_signals` and `activate_signal` tasks per schema +3. Return required fields as defined in response schemas +4. Use specified error codes +5. Enforce access control for private signals and activation keys + +### Orchestrator Conformance + +A conformant Signals Protocol orchestrator MUST: + +1. Authenticate with signal agents +2. Include required fields as defined in request schemas +3. Handle async activation responses +4. Use `activation_key` for campaign targeting + +## Implementation Notes + +### Multi-Platform Discovery + +Orchestrators MAY request signals across multiple platforms in a single `get_signals` call. + +Signal agents SHOULD return deployment information for all requested platforms. + +### Activation Timing + +Signal activation is typically asynchronous: +- Simple activations: 1-2 hours +- Complex deployments: up to 24-48 hours + +Orchestrators MUST NOT assume immediate availability after activation request. + +### Destination Type Selection + +The `activate_signal` request supports two destination types. The choice depends on the buyer's execution path: + +- Orchestrators buying through a Sales Agent SHOULD use `type: "agent"` destinations with the SA's URL. The SA handles downstream platform coordination — which DSP it uses is an implementation detail. +- Orchestrators buying directly on a DSP SHOULD use `type: "platform"` destinations. The orchestrator is responsible for ensuring the activation platform matches where campaigns will run. +- Signal agents MUST support both destination types per the destination schema. + +## Schema Reference + +| Schema | Description | +|--------|-------------| +| [`signals/get-signals-request.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-request.json) | get_signals request | +| [`signals/get-signals-response.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-response.json) | get_signals response | +| [`signals/activate-signal-request.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json) | activate_signal request | +| [`signals/activate-signal-response.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-response.json) | activate_signal response | +| [`core/deployment.json`](https://adcontextprotocol.org/schemas/3.0.13/core/deployment.json) | Deployment target | +| [`core/activation-key.json`](https://adcontextprotocol.org/schemas/3.0.13/core/activation-key.json) | Activation key | diff --git a/dist/docs/3.0.13/signals/tasks/activate_signal.mdx b/dist/docs/3.0.13/signals/tasks/activate_signal.mdx new file mode 100644 index 0000000000..da1234bc36 --- /dev/null +++ b/dist/docs/3.0.13/signals/tasks/activate_signal.mdx @@ -0,0 +1,537 @@ +--- +title: activate_signal +description: "activate_signal is the AdCP task for pushing audience segments to DSPs and sales agents. Supports async activation, deactivation for data governance, and returns platform-specific activation keys." +"og:title": "AdCP — activate_signal" +--- + + +**Task**: Activate a signal for use on a specific platform/account. + +**Response Time**: Minutes to days (asynchronous with potential human-in-the-loop) + +**Request Schema**: [`https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-request.json) +**Response Schema**: [`https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-response.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/activate-signal-response.json) + +The `activate_signal` task handles the entire activation lifecycle, including: +- Initiating the activation request +- Monitoring activation progress +- Returning the final deployment status + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `idempotency_key` | string | Yes | Client-generated unique key for this request. Prevents duplicate activations on retries. MUST be unique per (seller, request) pair. Min 16 chars. See [Idempotency](/dist/docs/3.0.13/building/by-layer/L1/security#idempotency) for normative semantics. | +| `signal_agent_segment_id` | string | Yes | The universal identifier for the signal to activate | +| `action` | string | No | `"activate"` (default) or `"deactivate"`. Deactivating removes segments from downstream platforms for data governance compliance (GDPR, CCPA). | +| `destinations` | Destination[] | Yes | Target destination(s) for activation (see Destination Object below) | +| `account` | [AccountRef](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents#account-references) | No | Account for this activation. Associates with a commercial relationship established via `sync_accounts`. | +| `pricing_option_id` | string | Yes (if signal has pricing options) | The pricing option selected from `pricing_options` in the [`get_signals`](/dist/docs/3.0.13/signals/tasks/get_signals) response. Records the buyer's pricing commitment at activation time. Pass this same value in subsequent `report_usage` calls. | + +### Destination Object + +Each deployment target uses a `type` field to discriminate between platform-based and agent-based deployments: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `type` | string | Yes | Discriminator: "platform" for DSPs, "agent" for sales agents | +| `platform` | string | Conditional* | Platform identifier (e.g., 'the-trade-desk', 'amazon-dsp'). Required when type="platform" | +| `agent_url` | string (URI) | Conditional* | URL identifying the sales agent. Required when type="agent" | +| `account` | string | No | Account identifier on the platform or agent | + +*`platform` is required when `type="platform"`, `agent_url` is required when `type="agent"`. + +**Activation Keys**: If the authenticated caller has access to any of the deployment targets in the request, the signal agent will include `activation_key` in the response for those deployments. + +**Permission Model**: The signal agent determines key inclusion based on the caller's authentication and authorization. For example: +- A sales agent receives keys for deployments matching its `agent_url` +- A buyer with credentials for multiple DSP platforms receives keys for all those deployments +- Access is determined by the signal agent's permission system, not by flags in the request + +## Response Structure + +All AdCP responses include: +- **message**: Human-readable summary of the activation status +- **context_id**: Session continuity identifier for tracking progress +- **data**: Task-specific payload (see Response Data below) + +The response structure is identical across protocols, with only the transport wrapper differing: +- **MCP**: Returns complete response as flat JSON +- **A2A**: Returns as artifacts with message in text part, data in data part + +For asynchronous operations like activation, both protocols support: +- **Status tracking**: Check completion status via task_id +- **Progress updates**: Real-time updates on activation progress + +## Response Data + +```json +{ + "deployments": [ + { + "type": "platform", + "platform": "string", + "account": "string", + "activation_key": { + "type": "segment_id", + "segment_id": "string" + }, + "estimated_activation_duration_minutes": "number", + "deployed_at": "string" + } + ], + "errors": [ + { + "code": "string", + "message": "string", + "field": "string", + "suggestion": "string", + "details": {} + } + ] +} +``` + +### Field Descriptions + +- **deployments**: Array of deployment results for each deployment target + - **platform**: Platform identifier for DSPs (either platform or agent_url will be present) + - **agent_url**: URL identifying the deployment agent (either platform or agent_url will be present) + - **account**: Account identifier if applicable + - **activation_key**: The key to use for targeting (see Activation Key below). Only present if the authenticated caller has access to this deployment. + - **estimated_activation_duration_minutes**: Estimated completion time for async operations + - **deployed_at**: ISO 8601 timestamp when activation completed +- **errors**: Optional array of errors and warnings encountered during activation + - **code**: Standardized error code for programmatic handling + - **message**: Human-readable error description with context + - **field**: Field path associated with the error (optional) + - **suggestion**: Suggested fix for the error (optional) + - **details**: Additional activation-specific error details (optional) + +### Activation Key Object + +The activation key represents how to use the signal on a deployment target. It can be either a segment ID or a key-value pair: + +**Segment ID format (typical for DSP platforms):** +```json +{ + "type": "segment_id", + "segment_id": "ttd_segment_12345" +} +``` + +**Key-Value format (typical for sales agents):** +```json +{ + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_intenders" +} +``` + +### Using Activation Keys + +The activation key tells the buyer how to reference the signal on the destination. The execution path depends on the destination type: + +**Platform destinations** — The `activation_key` contains a `segment_id`, a platform-native identifier. The signal agent pushed segment data to the DSP; the buyer references this key when configuring campaign targeting on that platform. The buyer is responsible for ensuring the activation platform matches where it will run campaigns. + +**Agent destinations** — The `activation_key` confirms the signal is live on the sales agent. The SA records the activation internally and applies signal-based targeting when fulfilling media buys through `create_media_buy`. The buyer does not need to know which DSP the SA uses — downstream platform coordination is the SA's responsibility. + +**Choosing a destination type**: Use `type: "platform"` when buying directly on a DSP. Use `type: "agent"` when buying through a Sales Agent — the SA coordinates its own DSP targeting as an implementation detail. + +## Protocol-Specific Examples + +The AdCP payload is identical across protocols. Only the request/response wrapper differs. + +### MCP Request - Sales Agent Activation +```json +{ + "tool": "activate_signal", + "arguments": { + "signal_agent_segment_id": "luxury_auto_intenders", + "pricing_option_id": "po_cpm_usd", + "destinations": [{ + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com" + }] + } +} +``` + +### MCP Response - Synchronous (Key-Value) +Immediate response with activation key: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "Signal successfully activated on Wonderstruck sales agent", + "context_id": "ctx-signals-123", + "deployments": [{ + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com", + "is_live": true, + "activation_key": { + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_intenders_v2" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] +} +``` + +### MCP Request - DSP Platform Activation +```json +{ + "tool": "activate_signal", + "arguments": { + "signal_agent_segment_id": "luxury_auto_intenders", + "pricing_option_id": "po_cpm_usd", + "destinations": [{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd" + }] + } +} +``` + +### MCP Response - Asynchronous (Segment ID) +Initial response: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "Initiating activation of 'Luxury Auto Intenders' on The Trade Desk", + "context_id": "ctx-signals-123", + "deployments": [{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd", + "is_live": false, + "estimated_activation_duration_minutes": 30 + }] +} +``` + +After polling for completion: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "Signal successfully activated on The Trade Desk", + "context_id": "ctx-signals-123", + "deployments": [{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ttd_agency123_lux_auto" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] +} +``` + +### A2A Request + +#### Natural Language Invocation +```javascript +await a2a.send({ + message: { + parts: [{ + kind: "text", + text: "Please activate the luxury_auto_intenders signal on The Trade Desk for account agency-123-ttd." + }] + } +}); +``` + +#### Explicit Skill Invocation +```javascript +await a2a.send({ + message: { + parts: [{ + kind: "data", + data: { + skill: "activate_signal", + parameters: { + signal_agent_segment_id: "luxury_auto_intenders", + pricing_option_id: "po_cpm_usd", + destinations: [{ + type: "platform", + platform: "the-trade-desk", + account: "agency-123-ttd" + }] + } + } + }] + } +}); +``` + +### A2A Response (with streaming) +Initial response: +```json +{ + "taskId": "task-signal-001", + "status": { "state": "working" } +} +``` + +Then via Server-Sent Events: +``` +data: {"message": "Validating signal access permissions..."} +data: {"message": "Configuring deployment on The Trade Desk..."} +data: {"message": "Finalizing activation..."} +data: {"status": {"state": "completed"}, "artifacts": [{ + "artifactId": "artifact-signal-activation-abc123", + "name": "signal_activation_result", + "parts": [ + {"kind": "text", "text": "Signal successfully activated on The Trade Desk"}, + {"kind": "data", "data": { + "context_id": "ctx-signals-123", + "deployments": [{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd", + "activation_key": { + "type": "segment_id", + "segment_id": "ttd_agency123_lux_auto" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] + }} + ] +}]} +``` + +### Protocol Transport +- **MCP**: Returns task_id for polling-based asynchronous operation tracking or webhook-based push notifications +- **A2A**: Uses Server-Sent Events for real-time progress updates and completion +- **Data Consistency**: Both protocols contain identical AdCP data structures and version information + +### Webhook Support + +For long-running activations (when initial response is `submitted`), configure a webhook to receive the complete response when activation completes: + +```javascript +const response = await session.call('activate_signal', + { + signal_agent_segment_id: "luxury_auto_intenders", + pricing_option_id: "po_cpm_usd", + destinations: [{ + type: "platform", + platform: "the-trade-desk", + account: "agency-123-ttd" + }] + }, + { + webhook_url: "https://buyer.com/webhooks/adcp/activate_signal/agent_id/op_id", + webhook_auth: { type: "bearer", credentials: "secret-token" } + } +); +``` + +When activation completes, you receive the full `activate_signal` response: + +```http +POST /webhooks/adcp/activate_signal/agent_id/op_id HTTP/1.1 +Content-Type: application/json +Authorization: Bearer secret-token + +{ + "deployments": [{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd", + "activation_key": { + "type": "segment_id", + "segment_id": "ttd_agency123_lux_auto" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] +} +``` + +See **[Webhooks](/dist/docs/3.0.13/building/by-layer/L3/webhooks)** for complete details on webhook configuration and reliability. + +## Scenarios + +### Async Activation - Initial Response (Pending) +**Message**: "I've initiated activation of 'Luxury Automotive Context' on PubMatic for account brand-456-pm. This typically takes about 60 minutes. I'll monitor the progress and notify you when it's ready to use." + +**Complete Response**: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "I've initiated activation of 'Luxury Automotive Context' on PubMatic for account brand-456-pm. This typically takes about 60 minutes. I'll monitor the progress and notify you when it's ready to use.", + "context_id": "ctx-signals-def456", + "deployments": [{ + "type": "platform", + "platform": "pubmatic", + "account": "brand-456-pm", + "is_live": false, + "estimated_activation_duration_minutes": 60 + }] +} +``` + +### Async Activation - Final Response (Deployed) +**Message**: "Excellent! The 'Luxury Automotive Context' signal is now live on PubMatic. You can start using it immediately with the activation key provided. The activation completed faster than expected - just 52 minutes." + +**Complete Response**: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "Excellent! The 'Luxury Automotive Context' signal is now live on PubMatic. You can start using it immediately with the activation key provided. The activation completed faster than expected - just 52 minutes.", + "context_id": "ctx-signals-def456", + "deployments": [{ + "type": "platform", + "platform": "pubmatic", + "account": "brand-456-pm", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "pm_brand456_peer39_lux_auto" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] +} +``` + +### Sync Activation - Sales Agent (Immediate) +**Message**: "Signal successfully activated on Wonderstruck sales agent. Use the key-value pair in your targeting configuration." + +**Complete Response**: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "Signal successfully activated on Wonderstruck sales agent. Use the key-value pair in your targeting configuration.", + "context_id": "ctx-signals-ghi789", + "deployments": [{ + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com", + "is_live": true, + "activation_key": { + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_context_v2" + }, + "deployed_at": "2025-01-15T14:31:00Z" + }] +} +``` + +### Success with Warnings +**Message**: "Successfully activated 'Luxury Automotive Context' on PubMatic, but noted some configuration issues. The signal is live and ready to use, though performance may be sub-optimal until the account settings are updated." + +**Complete Response**: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "Successfully activated 'Luxury Automotive Context' on PubMatic, but noted some configuration issues. The signal is live and ready to use, though performance may be sub-optimal until the account settings are updated.", + "context_id": "ctx-signals-def456", + "deployments": [{ + "type": "platform", + "platform": "pubmatic", + "account": "brand-456-pm", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "pm_brand456_peer39_lux_auto" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] +} +``` + + +Warnings about suboptimal configuration are conveyed in the `message` field. The `errors` array is reserved for actual failures — the activate_signal response uses a strict success/error discriminated union where `deployments` and `errors` are mutually exclusive. + + +### Error Response (Failed) +**Message**: "I couldn't activate the signal on PubMatic. Your account 'brand-456-pm' doesn't have permission to use Peer39 data. Please contact your PubMatic account manager to enable Peer39 access, then we can try again." + +**Complete Response**: +```json +{ + "$schema": "/schemas/3.0.13/signals/activate-signal-response.json", + "message": "I couldn't activate the signal on PubMatic. Your account 'brand-456-pm' doesn't have permission to use Peer39 data. Please contact your PubMatic account manager to enable Peer39 access, then we can try again.", + "context_id": "ctx-signals-def456", + "errors": [ + { + "code": "DEPLOYMENT_UNAUTHORIZED", + "message": "Account brand-456-pm not authorized for Peer39 data on PubMatic", + "field": "deployment.account", + "suggestion": "Contact your PubMatic account manager to enable Peer39 data access for your account", + "details": { + "account_id": "brand-456-pm", + "deployment_url": "https://pubmatic.com", + "data_provider": "peer39", + "required_permission": "third_party_data_access" + } + } + ] +} +``` + +## Error Codes + +### Activation Errors +- `REFERENCE_NOT_FOUND`: Referenced `signal_agent_segment_id` doesn't exist or is not accessible to the calling account (`error.field` identifies the failing parameter) +- `ACTIVATION_FAILED`: Could not activate signal for unspecified reasons +- `ALREADY_ACTIVATED`: Signal already active on the specified platform/account +- `DEPLOYMENT_UNAUTHORIZED`: Can't deploy to platform/account due to permissions +- `INVALID_PRICING_MODEL`: Requested pricing model not available for this signal + +### Configuration Warnings +- `SUBOPTIMAL_CONFIGURATION`: Signal activated but account settings may impact performance +- `SLOW_ACTIVATION`: Activation taking longer than expected but still in progress +- `FREQUENCY_CAP_RESTRICTIVE`: Signal activated but account frequency caps may reduce performance + +## Error Handling Philosophy + +### Status vs Errors +- **Task Status**: Indicates overall activation outcome (`deployed`, `failed`, etc.) +- **Errors Array**: Contains specific issues, warnings, and remediation steps +- **Partial Success**: Signal can be `deployed` while still having warnings in `errors` array + +### Error Types +- **Fatal Errors**: Prevent activation (status = `failed`) +- **Warnings**: Signal activates successfully but with caveats (status = `deployed` + errors) +- **Configuration Issues**: Non-blocking problems that affect performance + +## Usage Notes + +1. **Account-Specific**: Include the `account` parameter for account-specific activations +2. **Platform-Wide**: Omit the `account` parameter for platform-wide activations +3. **Async Operation**: This is a long-running task that provides status updates +4. **Monitoring**: Use task ID to monitor progress via polling or SSE +5. **Idempotent**: Safe to retry if activation fails + +## Implementation Guide + +### Generating Activation Messages + +The `message` field should provide clear status updates and actionable information: + +```python +def generate_activation_message(status, signal_info, request): + if status == "pending": + return f"I've initiated activation of '{signal_info.name}' on {request.platform} for account {request.account}. This typically takes about {signal_info.estimated_duration} minutes. I'll monitor the progress and notify you when it's ready to use." + + elif status == "processing": + progress_details = get_progress_details() + time_remaining = calculate_time_remaining() + return f"Good progress on the activation. {progress_details}. About {time_remaining} minutes remaining." + + elif status == "deployed": + actual_duration = calculate_actual_duration() + timing_note = "faster than expected" if actual_duration < signal_info.estimated_duration else "as expected" + return f"Excellent! The '{signal_info.name}' signal is now live on {request.platform}. You can start using it immediately in your campaigns with the ID '{signal_info.platform_id}'. The activation completed {timing_note} - just {actual_duration} minutes." + + elif status == "failed": + error_explanation = explain_error_in_context(error_code) + next_steps = get_remediation_steps(error_code) + return f"I couldn't activate the signal on {request.platform}. {error_explanation}. {next_steps}" +``` \ No newline at end of file diff --git a/dist/docs/3.0.13/signals/tasks/get_signals.mdx b/dist/docs/3.0.13/signals/tasks/get_signals.mdx new file mode 100644 index 0000000000..45a1909ae1 --- /dev/null +++ b/dist/docs/3.0.13/signals/tasks/get_signals.mdx @@ -0,0 +1,837 @@ +--- +title: get_signals +description: "get_signals is the AdCP task for discovering audience and contextual signals. Search by natural language or signal ID, filter by platform and CPM, and get real-time deployment status with activation keys." +"og:title": "AdCP — get_signals" +--- + + +**Task**: Discover signals based on description, with details about where they are deployed. + +**Response Time**: ~60 seconds (inference/RAG with back-end systems) + +**Request Schema**: [`https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-request.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-request.json) +**Response Schema**: [`https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-response.json`](https://adcontextprotocol.org/schemas/3.0.13/signals/get-signals-response.json) + +The `get_signals` task returns both signal metadata and real-time deployment status across platforms, allowing agents to understand availability and guide the activation process. + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `signal_spec` | string | Conditional | Natural language description of the desired signals. Required unless `signal_ids` is provided. | +| `signal_ids` | SignalID[] | Conditional | Specific signals to look up by data provider and ID. Required unless `signal_spec` is provided. | +| `account` | [AccountRef](/dist/docs/3.0.13/building/by-layer/L2/accounts-and-agents#account-references) | No | Account for this request. When provided, the signals agent returns per-account pricing options if configured. | +| `destinations` | Destination[] | No | Filter signals to those activatable on specific agents/platforms. When omitted, returns all signals available on the current agent. See Destination Object below. | +| `countries` | string[] | No | Countries where signals will be used (ISO 3166-1 alpha-2 codes) | +| `filters` | Filters | No | Filters to refine results (see Filters Object below) | +| `max_results` | number | No | **Deprecated.** Use `pagination.max_results` instead. When both are present, `pagination.max_results` takes precedence. Will be removed in AdCP 4.0. | +| `pagination` | object | No | Pagination envelope. `pagination.max_results` (max: 100, default: 50) controls page size; `pagination.cursor` (opaque token from previous response) advances pages. | + +### Destination Object + +Each deployment target uses a `type` field to discriminate between platform-based and agent-based deployments: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `type` | string | Yes | Discriminator: "platform" for DSPs, "agent" for sales agents | +| `platform` | string | Conditional* | Platform identifier (e.g., 'the-trade-desk', 'amazon-dsp'). Required when type="platform" | +| `agent_url` | string (URI) | Conditional* | URL identifying the sales agent. Required when type="agent" | +| `account` | string | No | Account identifier on the platform or agent | + +*`platform` is required when `type="platform"`, `agent_url` is required when `type="agent"`. + +**Destination filtering**: Signals are returned if they are available on *any* of the requested destinations (OR semantics). Destinations where a signal is not available are omitted from that signal's response `deployments` array. A `PARTIAL_COVERAGE` warning may be included when some destinations don't support the signal. + +**Activation Keys**: If the authenticated caller has access to any of the destinations in the request, the signal agent will include `activation_key` fields in the response for those deployments (when `is_live: true`). + +**Permission Model**: The signal agent determines key inclusion based on the caller's authentication and authorization. For example: +- A sales agent receives keys for deployments matching its `agent_url` +- A buyer with credentials for multiple DSP platforms receives keys for all those deployments +- Access is determined by the signal agent's permission system, not by flags in the request + +### Filters Object + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `catalog_types` | string[] | No | Filter by catalog type ("marketplace", "custom", "owned") | +| `data_providers` | string[] | No | Filter by specific data providers | +| `max_cpm` | number | No | Maximum CPM price filter. Excludes signals where all CPM-based pricing options exceed this value. Signals without CPM-based pricing options are not affected by this filter. | +| `min_coverage_percentage` | number | No | Minimum coverage requirement | + +## Response Structure + +All AdCP responses include: +- **message**: Human-readable summary of the operation result +- **context_id**: Session continuity identifier for follow-up requests +- **data**: Task-specific payload (see Response Data below) + +The response structure is identical across protocols, with only the transport wrapper differing: +- **MCP**: Returns complete response as flat JSON +- **A2A**: Returns as artifacts with message in text part, data in data part + +## Response Data + +```json +{ + "signals": [ + { + "signal_agent_segment_id": "string", + "name": "string", + "description": "string", + "signal_type": "string", + "data_provider": "string", + "coverage_percentage": "number", + "deployments": [ + { + "type": "agent", + "agent_url": "string", + "account": "string", + "is_live": "boolean", + "activation_key": { + "type": "segment_id", + "segment_id": "string" + }, + "estimated_activation_duration_minutes": "number" + } + ], + "pricing_options": [ + { + "pricing_option_id": "string", + "model": "cpm | percent_of_media | flat_fee | per_unit | custom", + "...": "..." + } + ] + } + ] +} +``` + +### Field Descriptions + +- **signals**: Array of matching signals + - **signal_agent_segment_id**: Unique identifier for the signal + - **name**: Human-readable signal name + - **description**: Detailed signal description + - **signal_type**: Type of signal. One of: + - `marketplace` — resold third-party segment (provider authorization verifiable via the provider's `adagents.json`) + - `owned` — first-party segment derived from data the signal agent directly owns + - `custom` — agent-native segment built on demand from models, composites, or buyer inputs (not attributable to a standing upstream provider) + - **data_provider**: Name of the data provider + - **coverage_percentage**: Percentage of audience coverage + - **deployments**: Array of destination deployments + - **agent_url**: URL identifying the destination agent + - **account**: Account identifier if applicable + - **is_live**: Whether signal is currently active on this deployment + - **activation_key**: The key to use for targeting (see Activation Key below). **Only present when `is_live=true` and the authenticated caller has access to this deployment.** + - **estimated_activation_duration_minutes**: Time to activate if not live + - **pricing_options**: Array of pricing options for this signal. Pass the selected `pricing_option_id` in `report_usage` for billing verification. + - **pricing_option_id**: Unique identifier for this pricing option + - **model**: Pricing model — `cpm`, `percent_of_media`, `flat_fee`, `per_unit`, or `custom` + - `model: "cpm"` — `cpm` (number, cost per thousand impressions), `currency` (ISO 4217) + - `model: "percent_of_media"` — `percent` (0–100), `currency` (ISO 4217), `max_cpm` (optional CPM cap: effective charge = `min(percent × media_spend_per_mille, max_cpm)`) + - `model: "flat_fee"` — `amount` (fixed charge), `currency` (ISO 4217), `period` (`monthly`, `quarterly`, `annual`, or `campaign`) + - `model: "per_unit"` — `unit` (what is counted), `unit_price` (cost per one unit), `currency` (ISO 4217) + - `model: "custom"` — `description` (human-readable), `metadata` (structured parameters), `currency` (optional). Escape hatch for performance kickers, tiered volume, hybrid formulas, or any construct the standard models cannot express. Buyers SHOULD route custom pricing through operator review before commitment. + +Select the pricing option that matches your billing model and pass its `pricing_option_id` in `report_usage` for billing verification. If a signal offers multiple models (e.g., CPM and flat fee), choose based on your expected delivery volume and campaign structure. + +### Activation Key Object + +The activation key represents how to use the signal on a deployment target. It can be either a segment ID or a key-value pair: + +**Segment ID format:** +```json +{ + "type": "segment_id", + "segment_id": "ttd_segment_12345" +} +``` + +**Key-Value format:** +```json +{ + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_intenders" +} +``` + +## Protocol-Specific Examples + +The AdCP payload is identical across protocols. Only the request/response wrapper differs. + +### MCP Request - Sales Agent Requesting Signals + +A sales agent querying for signals. Because the authenticated caller is wonderstruck.salesagents.com, the signal agent will include activation keys in the response: + +```json +{ + "tool": "get_signals", + "arguments": { + "signal_spec": "High-income households interested in luxury goods", + "destinations": [ + { + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com" + } + ], + "countries": ["US"], + "filters": { + "max_cpm": 5.0, + "catalog_types": ["marketplace"] + }, + "pagination": { + "max_results": 5 + } + } +} +``` + +### MCP Response - With Activation Key + +Because the authenticated caller matches the deployment target, the response includes the activation key: + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-response.json", + "message": "Found 1 luxury segment matching your criteria. Already activated for your sales agent.", + "context_id": "ctx-signals-123", + "signals": [ + { + "signal_id": { + "source": "catalog", + "data_provider_domain": "experian.com", + "id": "luxury_auto_intenders" + }, + "signal_agent_segment_id": "luxury_auto_intenders", + "name": "Luxury Automotive Intenders", + "description": "High-income individuals researching luxury vehicles", + "signal_type": "marketplace", + "data_provider": "Experian", + "coverage_percentage": 12, + "deployments": [ + { + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com", + "is_live": true, + "activation_key": { + "type": "key_value", + "key": "audience_segment", + "value": "luxury_auto_intenders_v2" + } + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_cpm_usd", + "model": "cpm", + "cpm": 3.50, + "currency": "USD" + } + ] + } + ] +} +``` + +### MCP Response - Multiple Pricing Options + +Some signals offer multiple pricing models. The buyer selects one and passes its `pricing_option_id` in `report_usage`: + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-response.json", + "message": "Found 1 segment matching your criteria. Three pricing options are available: CPM at $3.50, 15% of media spend, or $5,000/month flat fee.", + "context_id": "ctx-signals-456", + "signals": [ + { + "signal_id": { + "source": "catalog", + "data_provider_domain": "acmedata.com", + "id": "eco_conscious_shoppers" + }, + "signal_agent_segment_id": "eco_conscious_shoppers", + "name": "Eco-Conscious Shoppers", + "description": "Users with demonstrated interest in sustainable and eco-friendly products", + "signal_type": "marketplace", + "data_provider": "Acme Data", + "coverage_percentage": 18, + "deployments": [ + { + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "eco_seg_789" + } + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_eco_cpm", + "model": "cpm", + "cpm": 3.50, + "currency": "USD" + }, + { + "pricing_option_id": "po_eco_pom", + "model": "percent_of_media", + "percent": 15, + "max_cpm": 1.50, + "currency": "USD" + }, + { + "pricing_option_id": "po_eco_flat", + "model": "flat_fee", + "amount": 5000, + "period": "monthly", + "currency": "USD" + } + ] + } + ] +} +``` + +### MCP Request - Buyer Querying Multiple DSP Platforms + +A buyer checking availability across multiple DSP platforms: + +```json +{ + "tool": "get_signals", + "arguments": { + "signal_spec": "High-income households interested in luxury goods", + "destinations": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123" + }, + { + "type": "platform", + "platform": "amazon-dsp" + } + ], + "countries": ["US"], + "filters": { + "max_cpm": 5.0, + "catalog_types": ["marketplace"] + }, + "pagination": { + "max_results": 5 + } + } +} +``` + +### MCP Response - Buyer With Multi-Platform Access + +A buyer with credentials for both The Trade Desk and Amazon DSP receives keys for both platforms: + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-response.json", + "message": "Found 1 luxury segment matching your criteria. Already activated on The Trade Desk, pending activation on Amazon DSP.", + "context_id": "ctx-signals-123", + "signals": [ + { + "signal_id": { + "source": "catalog", + "data_provider_domain": "experian.com", + "id": "luxury_auto_intenders" + }, + "signal_agent_segment_id": "luxury_auto_intenders", + "name": "Luxury Automotive Intenders", + "description": "High-income individuals researching luxury vehicles", + "signal_type": "marketplace", + "data_provider": "Experian", + "coverage_percentage": 12, + "deployments": [ + { + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ttd_agency123_exp_lux_auto" + } + }, + { + "type": "platform", + "platform": "amazon-dsp", + "is_live": false, + "estimated_activation_duration_minutes": 60 + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_cpm_usd", + "model": "cpm", + "cpm": 3.50, + "currency": "USD" + } + ] + } + ] +} +``` + +### A2A Request + +#### Natural Language Invocation +```javascript +await a2a.send({ + message: { + parts: [{ + kind: "text", + text: "Find me signals for high-income households interested in luxury goods that can be deployed on The Trade Desk and Amazon DSP in the US, with a maximum CPM of $5.00." + }] + } +}); +``` + +#### Explicit Skill Invocation +```javascript +await a2a.send({ + message: { + parts: [{ + kind: "data", + data: { + skill: "get_signals", + parameters: { + signal_spec: "High-income households interested in luxury goods", + destinations: [ + { + type: "agent", + agent_url: "https://thetradedesk.com", + account: "agency-123" + }, + { + type: "agent", + agent_url: "https://advertising.amazon.com/dsp" + } + ], + countries: ["US"], + filters: { + max_cpm: 5.0, + catalog_types: ["marketplace"] + }, + pagination: { + max_results: 5 + } + } + } + }] + } +}); +``` + +### A2A Response +A2A returns results as artifacts with the same data structure: +```json +{ + "artifacts": [{ + "artifactId": "artifact-signal-discovery-def456", + "name": "signal_discovery_result", + "parts": [ + { + "kind": "text", + "text": "Found 1 luxury segment matching your criteria. Available on The Trade Desk, pending activation on Amazon DSP." + }, + { + "kind": "data", + "data": { + "context_id": "ctx-signals-123", + "signals": [ + { + "signal_agent_segment_id": "luxury_auto_intenders", + "name": "Luxury Automotive Intenders", + "description": "High-income individuals researching luxury vehicles", + "signal_type": "marketplace", + "data_provider": "Experian", + "coverage_percentage": 12, + "deployments": [ + { + "type": "agent", + "agent_url": "https://thetradedesk.com", + "account": "agency-123", + "is_live": true + }, + { + "type": "agent", + "agent_url": "https://advertising.amazon.com/dsp", + "is_live": false, + "estimated_activation_duration_minutes": 60 + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_cpm_usd", + "model": "cpm", + "cpm": 3.50, + "currency": "USD" + } + ] + } + ] + } + } + ] + }] +} +``` + +### Protocol Transport +- **MCP**: Direct tool call with arguments, returns complete response as flat JSON +- **A2A**: Skill invocation with input, returns structured artifacts with message and data separated +- **Data Consistency**: Both protocols contain identical AdCP data structures and version information + +## Scenarios + +### All Platforms Discovery + +Discover all available deployments across platforms: + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-request.json", + "signal_spec": "Contextual segments for luxury automotive content", + "destinations": [ + { "type": "platform", "platform": "index-exchange", "account": "agency-123-ix" }, + { "type": "platform", "platform": "openx" }, + { "type": "platform", "platform": "pubmatic", "account": "brand-456-pm" } + ], + "countries": ["US"], + "filters": { + "data_providers": ["Peer39"], + "catalog_types": ["marketplace"] + } +} +``` + +### Response + +**Message**: "Found luxury automotive contextual segment from Peer39 with 15% coverage. Live on Index Exchange and OpenX, pending activation on Pubmatic." + +**Payload**: +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-response.json", + "signals": [{ + "signal_id": { + "source": "catalog", + "data_provider_domain": "peer39.com", + "id": "peer39_luxury_auto" + }, + "signal_agent_segment_id": "peer39_luxury_auto", + "name": "Luxury Automotive Context", + "description": "Pages with luxury automotive content and high viewability", + "signal_type": "marketplace", + "data_provider": "Peer39", + "coverage_percentage": 15, + "deployments": [ + { + "type": "platform", + "platform": "index-exchange", + "account": "agency-123-ix", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ix_agency123_peer39_lux_auto" + } + }, + { + "type": "platform", + "platform": "index-exchange", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ix_peer39_luxury_auto_gen" + } + }, + { + "type": "platform", + "platform": "openx", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ox_peer39_lux_auto_456" + } + }, + { + "type": "platform", + "platform": "pubmatic", + "account": "brand-456-pm", + "is_live": false, + "estimated_activation_duration_minutes": 60 + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_cpm_usd", + "model": "cpm", + "cpm": 2.50, + "currency": "USD" + } + ] + }] +} +``` + +### Response Fields + +- **context_id** (string): Context identifier for session persistence +- **signals** (array): Array of matching signals + - **signal_agent_segment_id** (string): Universal identifier for the signal + - **name** (string): Human-readable signal name + - **description** (string): Detailed signal description + - **signal_type** (string): `marketplace` (resold third-party), `owned` (agent's first-party data), or `custom` (agent-native segment built on demand) + - **data_provider** (string): Provider of the signal data + - **coverage_percentage** (number): Estimated reach percentage + - **deployments** (array): Platform-specific deployment information + - **platform** (string): Target platform name + - **account** (string, nullable): Specific account if account-specific + - **is_live** (boolean): Whether signal is currently active + - **activation_key** (object): The key to use for targeting. Only present when `is_live=true` and the caller has access. See Activation Key Object above. + - **estimated_activation_duration_minutes** (number, optional): Time to activate if not live + - **pricing_options** (array): Array of pricing options available for this signal. Select one and pass its `pricing_option_id` in `report_usage`. + - **pricing_option_id** (string): Unique identifier for this pricing option + - **model** (string): Pricing model — `cpm`, `percent_of_media`, `flat_fee`, `per_unit`, or `custom` + +## Error Codes + +### Discovery Errors +- `REFERENCE_NOT_FOUND`: Referenced `signal_agent_segment_id` doesn't exist, + OR a private signal agent is not visible to this account. The same code is + returned whether the resource exists but is unauthorized or truly does not + exist — sellers MUST NOT distinguish the two (see `error.field` to + identify which typed parameter failed to resolve). See the uniform-response + MUST in error-handling.mdx. +- `AGENT_ACCESS_DENIED`: Authenticated agent's credentials did not authorize access to this signal agent + +### Discovery Warnings +- `PRICING_UNAVAILABLE`: Pricing data temporarily unavailable for one or more platforms +- `PARTIAL_COVERAGE`: Some requested platforms don't support this signal type +- `STALE_DATA`: Some signal metadata may be outdated due to provider refresh delays + +## Usage Notes + +1. **Authentication-Based Keys**: Activation keys are only returned when the authenticated caller matches one of the deployment targets +2. **Permission Security**: The signal agent determines key inclusion based on caller identity, not request flags +3. **Deployment Status**: Check `is_live` to determine if activation is needed +4. **Multiple Deployments**: Query multiple deployment targets to check availability across platforms +5. **Activation Required**: If `is_live` is false, use the `activate_signal` task +6. **The message field** provides a quick summary of the most relevant findings + +## Iterative refinement + +`get_signals` supports iterative refinement without a separate mode flag. The combination of `signal_spec` and `signal_ids` determines the operation: + +| Fields provided | Behavior | +|----------------|----------| +| `signal_spec` only | Discovery — find signals matching the description | +| `signal_ids` only | Exact lookup — return specific signals by ID | +| `signal_ids` + `signal_spec` | Refinement — start from known signals, adjust per the spec | + +To refine previous results, pass back the `signal_id` values from signals you want to keep, and provide an updated `signal_spec` describing what to change: + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-request.json", + "signal_spec": "Same audience but with broader coverage, ideally above 20%", + "signal_ids": [ + { + "source": "catalog", + "data_provider_domain": "experian.com", + "id": "luxury_auto_intenders" + } + ], + "destinations": [ + { + "type": "agent", + "agent_url": "https://wonderstruck.salesagents.com" + } + ], + "countries": ["US"] +} +``` + +The signal agent uses the provided IDs as a starting point and the spec as adjustment guidance, returning signals that reflect both the original selection and the requested changes (e.g., broader segments from the same provider, or comparable segments from alternative providers with higher coverage). + +### Response - Multiple Signals Found + +```json +{ + "message": "I found 3 signals matching your luxury goods criteria. The best option is 'Affluent Shoppers' with 22% coverage, already live across all requested platforms. 'High Income Households' offers broader reach (35%) but requires activation on OpenX. All signals are priced between $2-4 CPM.", + "context_id": "ctx-signals-abc123", + "signals": [ + { + "signal_agent_segment_id": "acme_affluent_shoppers", + "name": "Affluent Shoppers", + "description": "Users with demonstrated luxury purchase behavior", + "signal_type": "marketplace", + "data_provider": "Acme Data", + "coverage_percentage": 22, + "deployments": [ + { + "type": "platform", + "platform": "index-exchange", + "account": "agency-123-ix", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ix_agency123_acme_aff_shop" + } + }, + { + "type": "platform", + "platform": "openx", + "account": "agency-123-ox", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ox_agency123_affluent_789" + } + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_cpm_usd", + "model": "cpm", + "cpm": 3.50, + "currency": "USD" + } + ] + } + // ... more signals + ] +} +``` + +### Response - Partial Success with Warnings + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-response.json", + "message": "Found 2 luxury signals, but encountered some platform limitations. The 'Premium Auto Shoppers' signal has limited reach due to data restrictions, and pricing data is unavailable for one platform. Review the warnings below for optimization suggestions.", + "context_id": "ctx-signals-abc123", + "signals": [ + { + "signal_id": { + "source": "catalog", + "data_provider_domain": "experian.com", + "id": "premium_auto_shoppers" + }, + "signal_agent_segment_id": "premium_auto_shoppers", + "name": "Premium Auto Shoppers", + "description": "High-value automotive purchase intenders", + "signal_type": "marketplace", + "data_provider": "Experian", + "coverage_percentage": 8, + "deployments": [ + { + "type": "platform", + "platform": "the-trade-desk", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ttd_exp_auto_premium" + } + } + ], + "pricing_options": [ + { + "pricing_option_id": "po_cpm_usd", + "model": "cpm", + "cpm": 4.50, + "currency": "USD" + } + ] + } + ], + "errors": [ + { + "code": "PRICING_UNAVAILABLE", + "message": "Pricing data temporarily unavailable for The Trade Desk platform", + "field": "signals[0].pricing_options", + "suggestion": "Retry in 15-30 minutes when platform pricing feed updates", + "details": { + "affected_platform": "the-trade-desk", + "last_updated": "2025-01-15T12:00:00Z", + "retry_after": 1800 + } + }, + { + "code": "PRICING_UNAVAILABLE", + "message": "Pricing data temporarily unavailable for Amazon DSP", + "field": "filters.platforms", + "suggestion": "Pricing will be available during activation, or try again later", + "details": { + "affected_platform": "amazon-dsp", + "retry_after": 1800 + } + } + ] +} +``` + +### Response - No Signals Found + +```json +{ + "$schema": "/schemas/3.0.13/signals/get-signals-response.json", + "message": "I couldn't find any signals matching 'underwater basket weavers' in the requested platforms. This appears to be a very niche audience. Consider broadening your criteria to 'craft enthusiasts' or 'hobby communities' for better results. Alternatively, we could create a custom signal for this specific audience.", + "context_id": "ctx-signals-abc123", + "signals": [] +} +``` + +## Implementation Guide + +### Generating Signal Messages + +The `message` field should provide actionable insights: + +```python +def generate_signals_message(signals, request): + if not signals: + return generate_no_signals_message(request.signal_spec) + + best_signal = find_best_signal(signals) + + if len(signals) == 1: + signal = signals[0] + deployment_status = get_deployment_summary(signal, request.destinations) + pricing = signal.pricing_options[0] if signal.pricing_options else None + if pricing: + p = pricing.pricing + if p.model == "cpm": + price_commentary = f"Priced at ${p.cpm:.2f} CPM {p.currency}." + elif p.model == "percent_of_media": + cap = f", capped at ${p.max_cpm:.2f} CPM" if getattr(p, "max_cpm", None) else "" + price_commentary = f"Priced at {p.percent}% of media spend{cap}." + elif p.model == "flat_fee": + price_commentary = f"Flat fee of {p.amount} {p.currency} per {p.period}." + else: + price_commentary = "" + else: + price_commentary = "" + return f"I found a perfect match: '{signal.name}' from {signal.data_provider} with {signal.coverage_percentage}% coverage. {deployment_status} {price_commentary}" + else: + return f"I found {len(signals)} signals matching your {extract_key_criteria(request.signal_spec)} criteria. {describe_best_option(best_signal)} {get_pricing_range(signals)}." + +def get_deployment_summary(signal, requested_deployments): + live = [d for d in signal.deployments if d.is_live] + pending = [d for d in signal.deployments if not d.is_live] + + if not pending: + return "Already live on all requested deployments, ready to use immediately." + elif live: + activation_time = max((d.estimated_activation_duration_minutes or 0) for d in pending) + return f"Live on {len(live)} deployment(s). Activation on {len(pending)} more would take about {activation_time} minutes." + else: + return "Requires activation on all deployments, which typically takes 1-2 hours." +``` \ No newline at end of file diff --git a/dist/docs/3.0.13/spec-guidelines.md b/dist/docs/3.0.13/spec-guidelines.md new file mode 100644 index 0000000000..bbd80e8f33 --- /dev/null +++ b/dist/docs/3.0.13/spec-guidelines.md @@ -0,0 +1,350 @@ +--- +title: Specification Guidelines +description: "AdCP specification guidelines: type naming rules, discriminated union patterns, field naming conventions, and style standards for writing protocol spec pages." +"og:title": "AdCP — Specification Guidelines" +--- + +# 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 +// ❌ 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 +// ✅ 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 +// 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 + +## Enum Design + +### Enum File Structure + +All enums should live in `/schemas/3.0.13/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 + +## Field Design + +### Discriminated Unions + +When objects can have multiple shapes, always use explicit discriminator fields: + +```json +{ + "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 +// ✅ 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 +// ❌ 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`, `openai_product_feed` in `brand.json` (enum values) +- 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. + +### 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.0.13/` +2. **Maintain v1**: Keep old schemas functional +3. **Document migration**: Provide before/after examples +4. **Deprecation period**: Support both versions for defined period + +## 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 changeset describing the 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 +- [ ] 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.0.13/` +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 diff --git a/dist/docs/3.0.13/sponsored-intelligence/implementing-si-agents.mdx b/dist/docs/3.0.13/sponsored-intelligence/implementing-si-agents.mdx new file mode 100644 index 0000000000..60bc92756a --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/implementing-si-agents.mdx @@ -0,0 +1,270 @@ +--- +title: Implementing SI Agents +description: "Build an AdCP Sponsored Intelligence agent. Implement si_get_offering, si_initiate_session, si_send_message, and si_terminate_session as MCP tools to serve conversational brand experiences." +"og:title": "AdCP — Implementing SI Agents" +--- + +This guide helps brands implement Sponsored Intelligence agents. Once implemented, your agent can be invoked by SI hosts like Addy, ChatGPT, or other AI assistants. + +## Quick Start + +An SI agent is an MCP or A2A server that implements four tasks: + +1. `si_get_offering` - Respond to offering lookups (details, availability, products) +2. `si_initiate_session` - Start a conversation +3. `si_send_message` - Exchange messages +4. `si_terminate_session` - End the conversation + +## Capability Discovery + +SI agents expose their capabilities through the standard AdCP `get_adcp_capabilities` task. When a host calls this task, your agent returns its SI configuration: + +```json +{ + "adcp": { "major_versions": [2] }, + "supported_protocols": ["sponsored_intelligence"], + "sponsored_intelligence": { + "endpoint": { + "transports": [ + { "type": "mcp", "url": "https://yourbrand.example/si-agent" } + ] + }, + "capabilities": { + "modalities": { + "conversational": true, + "voice": false, + "video": false, + "avatar": false + }, + "components": { + "standard": ["text", "link", "image", "product_card", "carousel", "action_button"], + "extensions": {} + }, + "commerce": { + "acp_checkout": false + } + }, + "brand": { "domain": "yourbrand.example" } + } +} +``` + +This unified discovery mechanism lets hosts discover SI capabilities alongside other AdCP protocols. + +## Reference Implementation + + +Reference implementations are coming soon. When available, they will demonstrate: +- All four SI tasks implemented as MCP tools +- Session management with timeout handling +- Identity and consent handling +- UI element generation +- ACP checkout handoff + + +### Task Structure + +Each SI task follows the MCP tool pattern: + +```typescript +server.tool( + "si_initiate_session", + "Start a conversational session with this brand agent", + { + intent: { type: "string", description: "Natural language user intent" }, + identity: { type: "object", description: "User identity with consent" }, + media_buy_id: { type: "string", description: "AdCP media buy ID (optional)" }, + offering_id: { type: "string", description: "Brand-specific offering reference (optional)" }, + }, + async ({ intent, identity, media_buy_id, offering_id }) => { + // Your implementation here + return { + content: [{ + type: "text", + text: JSON.stringify({ + session_id: "sess_abc123", + response: { message: "Hello! How can I help?" }, + negotiated_capabilities: { /* ... */ } + }) + }] + }; + } +); +``` + +## Key Implementation Considerations + +### 1. The Conversation Handoff + +The `intent` field in `si_initiate_session` is the host's handoff message — what the host AI tells your brand agent about what the user is trying to do. This is visible to the user as part of the conversation flow. + +For example, when a user says "I need to fly to Boston next week" in ChatGPT, and the host decides to connect them to Delta's SI agent, the handoff might look like: + +> "I'm connecting you with Delta to help with your Boston flight. They can check availability and offerings for you." + +Your brand agent receives the intent and should respond naturally — as if continuing the conversation: + +```typescript +// Your agent is an AI that responds conversationally +// The intent tells you what the user needs — just help them + +// Good response: +"Hi! I'd be happy to help you find a flight to Boston. +When next week were you thinking - any preferred time of day?" + +// Not this - don't mechanically echo back parsed data: +"I detected: category=flight, destination=Boston, timeframe=next_week" +``` + +The key is that SI is a conversation, not an API call. Your brand agent should feel like talking to a helpful person, not filling out a form. + +### 2. Identity Handling + +When `consent_granted` is true, you receive real PII: + +```typescript +async function handleIdentity(identity: Identity) { + if (!identity.consent_granted) { + // Anonymous session - can still help, just can't personalize + return null; + } + + // Look up existing customer by email + const customer = await lookupCustomer(identity.user.email); + + if (customer) { + // Personalize based on history + return { + name: customer.preferred_name || identity.user.name, + loyalty_status: customer.loyalty_tier, + preferences: customer.preferences, + }; + } + + // New customer - use provided identity + return { + name: identity.user.name, + email: identity.user.email, + }; +} +``` + +### 3. UI Elements + +Return structured data that hosts can render: + +```typescript +const uiElements = [ + // Product card for a specific item + { + type: "product_card", + data: { + title: "Premium Widget", + subtitle: "Best seller", + price: "$99", + image_url: "https://...", + cta: { label: "Add to Cart", action: "add_to_cart", payload: { sku: "WIDGET-001" } }, + }, + }, + + // Carousel for browsing options + { + type: "carousel", + data: { + title: "You might also like", + items: [ + { title: "Option A", price: "$49" }, + { title: "Option B", price: "$79" }, + ], + }, + }, + + // Action button for explicit CTA + { + type: "action_button", + data: { + label: "Complete Purchase", + action: "checkout", + payload: { items: ["WIDGET-001"] }, + }, + }, +]; +``` + +### 4. Session Management + +Sessions should have timeouts and cleanup: + +```typescript +const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +function cleanupExpiredSessions() { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.last_activity.getTime() > SESSION_TIMEOUT_MS) { + sessions.delete(id); + } + } +} + +// Run cleanup periodically +setInterval(cleanupExpiredSessions, 60 * 1000); +``` + +### 5. Handoff to ACP + +When the user is ready to purchase, signal a handoff: + +```typescript +if (userWantsToPurchase) { + return { + session_status: "pending_handoff", + handoff: { + type: "transaction", + intent: { + action: "purchase", + product: selectedProduct, + price: { amount: 99, currency: "USD" }, + }, + context_for_checkout: { + conversation_summary: "User selected Premium Widget after discussing features", + applied_offers: appliedOffers, + }, + }, + }; +} +``` + +## Testing Your Endpoint + +### Local Testing + +Use the MCP Inspector to test your endpoint: + +```bash +npx @anthropic-ai/mcp-inspector your-si-agent +``` + +### Integration Testing with Addy + +Once your endpoint is registered: + +1. Join the AgenticAdvertising.org Slack workspace +2. Start a conversation with Addy +3. Say "connect me with [Your Brand]" +4. Addy will invoke your SI endpoint + +## Registering Your SI Agent + +To make your SI agent available through Addy and other hosts: + +1. Implement the five SI tasks (`get_adcp_capabilities` + four SI tasks) +2. Ensure `get_adcp_capabilities` returns your SI endpoint and capabilities +3. Contact the AgenticAdvertising.org team to register your endpoint +4. Test the integration in the staging environment + +## Next Steps + +- Review the [Task Reference](./tasks/) for detailed schema specifications +- Explore the [SI Protocol Overview](./overview) for conceptual understanding +- Join the [Working Group](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) to discuss implementation questions diff --git a/dist/docs/3.0.13/sponsored-intelligence/implementing-si-hosts.mdx b/dist/docs/3.0.13/sponsored-intelligence/implementing-si-hosts.mdx new file mode 100644 index 0000000000..bf2543274e --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/implementing-si-hosts.mdx @@ -0,0 +1,138 @@ +--- +title: Implementing SI Hosts +description: "Integrate AdCP Sponsored Intelligence into your AI platform. Discover brand agents, render standard UI components, manage session lifecycle, handle consent flows, and hand off to ACP for commerce." +"og:title": "AdCP — Implementing SI Hosts" +--- + +This guide helps AI platforms implement Sponsored Intelligence host capabilities. Once implemented, your AI assistant can invoke brand agents for rich conversational commerce experiences. + +## Quick Start + +An SI host needs to: + +1. **Discover** brand agents via SI manifests +2. **Get offering details** before handoff (optional but recommended) +3. **Negotiate** capabilities with brand agents +4. **Manage** sessions (initiate, message, terminate) +5. **Render** standard UI components +6. **Handle** commerce handoffs + +## Architecture Overview + +```mermaid +flowchart TB + subgraph Platform["Your AI Platform"] + CE[Conversation Engine] + SM[SI Session Manager] + UI[UI Renderer] + MCP[MCP Client] + + CE --> SM + SM --> UI + CE --> MCP + SM --> MCP + UI --> MCP + end + + subgraph Brand["Brand Agent (MCP Server)"] + T1[si_get_offering] + T2[si_initiate_session] + T3[si_send_message] + T4[si_terminate_session] + end + + MCP --> Brand +``` + +## Reference Implementation + + +Reference implementations are coming soon. When available, they will demonstrate: +- MCP client connection to brand agents +- Session management with timeout handling +- Consent flow implementation +- UI component rendering +- Commerce handoff to ACP + + +## Key Implementation Considerations + +### 1. Consent Flow + +Before sharing user identity with brand agents, you must obtain explicit consent: + +1. Present a clear consent dialog identifying the brand and requested data +2. Link to the brand's privacy policy +3. Allow the user to select which fields to share (name, email, shipping address) +4. Record consent timestamp and scope for the identity object + +If consent is denied, create an anonymous session with `consent_granted: false`. + +### 2. UI Component Rendering + +Hosts must render all standard UI components defined in the SI protocol: + +| Component | Purpose | Required Fields | +|-----------|---------|-----------------| +| `text` | Conversational message | `message` | +| `link` | URL with label | `url`, `label` | +| `image` | Single image | `url`, `alt` | +| `product_card` | Product display with CTA | `title`, `price` | +| `carousel` | Array of cards/images | `items` | +| `action_button` | CTA that triggers callback | `label`, `action` | + +When a user clicks an `action_button`, send an `action_response` via `si_send_message` with the action identifier and any payload. + +### 3. Offering Lookup Flow + +The recommended flow for sponsored results: + +1. **Get offering details** (anonymous) - Retrieve offering info and matching products +2. **Show offering to user** - Display offering details, products, and ask if they want to connect +3. **Get consent** - If yes, present consent dialog +4. **Initiate session** - Include offering token from step 1 + +### 4. Commerce Handoff + +When a session returns `session_status: "pending_handoff"`: + +- For `handoff.type: "transaction"` - Initiate ACP checkout with the provided intent +- Terminate the SI session with reason `handoff_transaction` + +### 5. Session Management + +- Implement session timeouts (recommended: 5 minutes of inactivity) +- Track session state locally and clean up on termination +- Handle standard error codes: `SESSION_NOT_FOUND`, `SESSION_TERMINATED`, `RATE_LIMITED` +- Handle SI-specific error codes: `offer_unavailable`, `capability_unsupported` + +## Testing Your Implementation + +### Local Testing with Brand Simulator + +```bash +# Run the SI brand simulator +npx @adcontextprotocol/si-simulator + +# Connect your host to localhost:3001 +``` + +### Integration Checklist + +- [ ] Can discover brand agents via SI manifest +- [ ] Can get offering details (anonymous, no PII) +- [ ] Can initiate sessions with/without identity +- [ ] Can send messages and receive responses +- [ ] Can handle all termination reasons +- [ ] Renders all standard components correctly +- [ ] Handles action buttons and callbacks +- [ ] Implements proper consent flow +- [ ] Handles commerce handoffs +- [ ] Implements session timeout + +## Next Steps + +- Review the [SI Specification](./specification) for normative requirements +- See [Implementing SI Agents](./implementing-si-agents) for brand-side implementation +- Explore the [Task Reference](./tasks/) for detailed schema specifications +- Join the [Community](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) for implementation support diff --git a/dist/docs/3.0.13/sponsored-intelligence/measurement.mdx b/dist/docs/3.0.13/sponsored-intelligence/measurement.mdx new file mode 100644 index 0000000000..530aff1c29 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/measurement.mdx @@ -0,0 +1,33 @@ +--- +title: Measurement +description: "Sponsored Intelligence measurement — engagement metrics, conversion tracking, and commerce in the conversation." +"og:title": "AdCP — SI measurement" +sidebarTitle: Measurement +--- + +# Measurement + +Sponsored Intelligence measurement centers on engagement and intent signals rather than traditional impression-based metrics. + +## Key metrics + +| Metric | Field | Description | +|---|---|---| +| Engagements | `engagements` | User interactions with sponsored content — clicks, expansions, follow-up questions about the brand | +| Clicks | `clicks` | Outbound clicks to advertiser destination | +| Cost per click | `rate` (CPC) | Average cost per click — the primary pricing model for most SI products | +| Cost per engagement | Derived: `spend / engagements` | A reporting metric (not a pricing model) that measures efficiency across all interaction types | + +## Conversion tracking + +AI platforms that support conversion tracking declare it in `get_adcp_capabilities` under `conversion_tracking`. Buyers set up event sources via `sync_event_sources` and use `kind: "event"` optimization goals on packages — the same pattern as any other channel. See [Optimization & Reporting](/dist/docs/3.0.13/media-buy/media-buys/optimization-reporting) for details. + +## Measurement does not change + +AdCP changes how you buy media, not how you measure it. Your existing measurement stack — media mix modeling, mobile measurement partners, multi-touch attribution, incrementality testing — works the same way. Sponsored Intelligence is a new channel in your media plan, not a new measurement paradigm. + +The protocol does make measurement easier in one specific way: because you push conversion events into platforms via `sync_event_sources`, the platform can optimize toward your real business outcomes instead of proxy metrics. But how you evaluate whether that spend was worth it uses the same tools and frameworks you use today. + +## Beyond ads: commerce in the conversation + +AI surfaces are uniquely positioned for commerce. A user asking an AI assistant about running shoes is expressing intent in a context where the platform can recommend, compare, and — eventually — help the user buy. Today, AdCP handles the advertising layer: catalogs, media buys, and delivery. Offering catalogs via `sync_catalogs` already enable commerce handoffs through [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) sessions, where the user can browse products and transition to checkout. As commerce protocols mature, the path from "interested" to "purchased" will happen entirely within the conversation. diff --git a/dist/docs/3.0.13/sponsored-intelligence/monetizing-ai.mdx b/dist/docs/3.0.13/sponsored-intelligence/monetizing-ai.mdx new file mode 100644 index 0000000000..5b2ca208bf --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/monetizing-ai.mdx @@ -0,0 +1,121 @@ +--- +title: Monetizing AI surfaces +sidebarTitle: Monetizing AI +description: "A practical guide for brands, agencies, and small businesses who want to advertise on AI assistants, AI search engines, and other AI platforms." +"og:title": "AdCP — Monetizing AI surfaces" +--- + +# Monetizing AI surfaces + +You have probably noticed AI assistants recommending products, AI search engines surfacing brand results, and conversational agents helping users make purchase decisions. This is a new advertising channel — and it is growing fast. + +If you have run Amazon Ads or Walmart Connect, you already understand the basic model: you push a product feed into a platform, and the platform merchandises your products to the right people. AI platforms work the same way, but they can do more with your data — generating custom responses, making contextual recommendations, and even handing off to your own brand agent for a full product consultation. + +This is Sponsored Intelligence. It works differently from display, social, or CTV. There is no creative to upload. No audience segment to target. You give the AI platform everything it needs to represent your brand well — your products, your brand voice, your rules — and the platform generates the right message in the right moment. + +This guide is for anyone on the buy side: agency trading desks evaluating a new channel, brand leaders rethinking their media approach, and small businesses looking to reach customers where they are increasingly spending time. For the technical protocol details, see the [Sponsored Intelligence protocol](/dist/docs/3.0.13/sponsored-intelligence/overview). + +## Why existing approaches fall short + +**Insertion orders** assume you are trafficking finished creative into predetermined placements. AI platforms generate creative on the fly from your data — there is nothing to traffic. + +**Programmatic buying** sends a thin signal (a page URL, a device type, maybe a user ID) to a remote decision-maker. That decision-maker lacks the conversation context that makes AI advertising work. The platform closest to the user has the context. Sending bid requests away from that context is the wrong direction. + +**Direct deals** can work for a single platform, but every AI platform has its own API, its own data requirements, its own reporting format. Building custom integrations with each one is the same fragmentation problem the industry spent a decade solving in programmatic. + +AdCP exists because there is no legacy approach that works here. It is a standard protocol for pushing your data into AI platforms so they can generate effective ads on your behalf. + +## The shift: from campaigns to ingredients + +In traditional advertising, the buyer's job is to set up campaigns and build creative. In Sponsored Intelligence, the buyer's job is to **provide ingredients and define goals**. + +Think about who has the most information. The AI platform is the one in conversation with the user. It knows what the person asked, what they care about, what they have already discussed. You know your brand, your products, your goals. The protocol connects these two sides: you push your ingredients in, and the platform assembles the best possible outcome. + +Sponsored Intelligence extends this pattern to conversational and generative experiences across every AI platform. + +The better your ingredients, the better the results. + +## What you provide + +Everything you push into an AI platform is a building block the platform uses to create, target, and optimize ads. + +| If you are... | You push... | The platform can... | +|---|---|---| +| An e-commerce brand | Products with titles, descriptions, prices, images | Recommend specific products when users ask about your category | +| A travel company | Flights, hotels, packages with dates and pricing | Suggest relevant trips based on the conversation | +| An employer | Job listings with roles, locations, requirements | Surface open positions to qualified candidates | +| A retailer | Store locations and local inventory | Direct users to nearby stores with in-stock items | +| A services company | Service offerings and promotions | Match capabilities to what the user is looking for | + +Beyond catalogs, you also provide: +- **Brand identity** — voice, visual guidelines, and positioning so the platform sounds like your brand +- **Content standards** — suitability rules enforced at generation time, before the ad is created +- **Conversion events** — real business outcomes so the platform optimizes toward what matters +- **Optimization goals** — target cost per engagement, cost per conversion, or ROAS + +## Your measurement stack does not change + +Your existing measurement stack — media mix modeling, MMPs, multi-touch attribution, incrementality testing — works the same way it always has. Sponsored Intelligence is a new channel in your media plan, not a new measurement paradigm. The protocol does make one thing easier: because you push conversion events into platforms, they can optimize toward real business outcomes instead of proxy metrics. But how you evaluate whether that spend was worth it uses the same tools and frameworks you use today. See [measurement](/dist/docs/3.0.13/sponsored-intelligence/measurement) for details. + +## Getting started by role + +### Brands with agencies + +Your primary job is **data quality**. The effectiveness of your campaigns depends directly on what you push in. + + + +### Own your catalogs + +Make sure your product data is rich, accurate, and current. Detailed descriptions, high-quality images, structured attributes (size, color, category), and accurate pricing. If your catalog is thin, your ads will be thin. + +### Define your brand identity + +Provide your voice, visual guidelines, and positioning. This is not a nice-to-have — it is the difference between sponsored content that sounds like your brand and content that sounds generic. + +### Set your content standards + +Define where your brand can and cannot appear. Be specific. AI platforms enforce these rules during ad generation, which gives you stronger control than you have in any other channel. + +### Brief your agency — or experiment yourself + +Your agency manages campaigns through buyer agents that speak AdCP. The protocol gives agencies more leverage to automate and serve you better. You can also experiment with your own brand agents alongside agency relationships, the same way some brands run Amazon Ads in-house while agencies handle everything else. + + + +### Agencies and trading desks + +A buyer agent fills the same role that a DSP fills in programmatic — but it is not limited to programmatic. It sits alongside your DSP. Your existing programmatic stack, measurement, and reporting do not go away. You add a buyer agent that can reach AI platforms, and over time, it can reach any channel where sellers implement the protocol. + +A buyer agent connects to any AI platform that implements the protocol. It pushes client data in (catalogs, brand identity, content standards, conversion events), discovers available products, executes campaigns, and pulls delivery reports — across every platform, through one interface. What you build once works everywhere. + +Build on the [AdCP SDKs](/dist/docs/3.0.13/building/by-layer/L0). The first agencies with working buyer agents will capture client demand faster than those negotiating direct deals platform by platform. + +### Small and mid-size businesses + +Work through a partner — an ad network, a platform integration, or a tool built into the commerce platform you already use — and the partner handles the protocol plumbing. + +Your job is straightforward: +- **Provide a good product feed.** If you sell on Shopify, Etsy, or any e-commerce platform, you already have one. +- **Set up your brand basics.** Your name, logo, voice, and any rules about where your brand should or should not appear. +- **Define what success looks like.** Sales? Store visits? Sign-ups? Your partner needs to know what to optimize toward. + +To find a partner, [ask Addie](https://agenticadvertising.org/chat) — she can help match you with the right option. You can also [browse the member directory](https://agenticadvertising.org/members) directly. + +## What's next + + + Tell Addie "I want to get certified." The free Basics track (A1–A3, about 50 minutes) teaches the protocol fundamentals. The Buyer track (C1–C4) teaches you to build a working buyer agent — no programming experience required. + + + + + +- [Sponsored Intelligence protocol](/dist/docs/3.0.13/sponsored-intelligence/overview) — Full technical protocol with product spectrum, ad networks, workflows, and measurement +- [Catalogs](/dist/docs/3.0.13/creative/catalogs) — How product, offering, store, and inventory catalogs work +- [Brand identity](/dist/docs/3.0.13/brand-protocol/brand-json) — The `brand.json` specification for voice, visual guidelines, and positioning +- [Content standards](/dist/docs/3.0.13/governance/overview) — How brand suitability rules are defined, shared, and enforced +- [SDKs and integration](/dist/docs/3.0.13/building/by-layer/L0) — JavaScript and Python SDKs for building buyer agents + + + diff --git a/dist/docs/3.0.13/sponsored-intelligence/networks.mdx b/dist/docs/3.0.13/sponsored-intelligence/networks.mdx new file mode 100644 index 0000000000..5a18f2dc2f --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/networks.mdx @@ -0,0 +1,196 @@ +--- +title: Ad networks +description: "How AI ad networks aggregate inventory across multiple AI platforms using AdCP — product modeling, account chains, catalog forwarding, governance, delivery reporting, and SI Chat Protocol routing." +"og:title": "AdCP — SI ad networks" +sidebarTitle: Ad networks +--- + +# Ad networks + +Ad networks aggregate inventory across multiple AI platforms into a single seller interface. The protocol supports this topology natively — a network is a seller agent that represents multiple publisher properties it doesn't own. + +## How a network appears + +To **buyer agents**, the network is a standard seller agent. Buyers connect to the network's MCP server, push catalogs and data in via standard tasks, and execute buys. To the **underlying AI platforms**, the network is an operator — it holds accounts on each platform and forwards the buyer's catalog data, brand identity, and content standards. + +## Product modeling for networks + +A network's products can span multiple AI platforms using `publisher_properties`: + +```json +{ + "product_id": "sponsored_response_ai_network", + "name": "Sponsored responses - AI assistant network", + "description": "Sponsored responses across multiple AI assistants. The network routes to the best-matching platform based on user context and brand relevance.", + "channels": ["sponsored_intelligence"], + "publisher_properties": [ + { "publisher_domain": "assistant-alpha.example.com", "selection_type": "all" }, + { "publisher_domain": "assistant-beta.example.com", "selection_type": "all" }, + { "publisher_domain": "search-gamma.example.com", "selection_type": "all" } + ], + "format_ids": [ + { "agent_url": "https://ads.ai-network.example.com", "id": "sponsored_response" } + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "network_cpc", + "pricing_model": "cpc", + "floor_price": 0.75, + "price_guidance": { "p50": 2.50, "p75": 4.00 }, + "currency": "USD", + "min_spend_per_package": 1000 + } + ], + "metric_optimization": { + "supported_metrics": ["clicks", "engagements"], + "supported_targets": ["cost_per"] + }, + "creative_policy": { + "co_branding": "none", + "landing_page": "any", + "templates_available": true + }, + "catalog_types": ["product", "offering"] +} +``` + +The network decides which platform serves each impression based on context, relevance, and performance. The buyer doesn't need to know which platform was selected — they see unified delivery reporting from the network. + +## Account model for networks + +Networks typically use implicit accounts (`require_operator_auth: false`). The buyer agent is trusted and declares brands via `sync_accounts`. The network then manages its own accounts with each underlying AI platform: + +``` +Buyer agent -> Network (implicit accounts, agent-trusted) +Network -> AI Platform A (explicit accounts, operator auth) +Network -> AI Platform B (explicit accounts, operator auth) +``` + +Declare capabilities accordingly: + +```json +{ + "adcp": { "major_versions": [3] }, + "supported_protocols": ["media_buy", "creative"], + "account": { + "require_operator_auth": false, + "supported_billing": ["operator", "agent"], + "required_for_products": false, + "sandbox": true + } +} +``` + +## Catalog forwarding + +Networks receive catalogs from buyers via `sync_catalogs` and forward them to the relevant AI platforms. The same task works on both legs — the network acts as a buyer when syncing catalogs to each platform. This is the core data pipe: brand catalog data flows from buyer to network to platform, giving each platform the raw material to generate ads. + +## Governance and content standards + +Networks can enforce [governance policies](/dist/docs/3.0.13/governance/overview) at the routing layer before forwarding to platforms. When a buyer pushes content standards, the network applies them when selecting which platforms and contexts are eligible — then forwards the policies to each platform so they're also enforced at creative generation time. This gives brands two layers of suitability enforcement: the network's routing decisions and the platform's generation constraints. + +## Delivery reporting + +Networks aggregate delivery data from underlying platforms and present unified reporting to buyers via `get_media_buy_delivery`. The buyer sees a single delivery report per media buy — the network handles the per-platform breakdown internally. Networks that want to offer platform-level transparency can use `reporting_dimensions` to expose placement-level breakdowns. + +## Declaring your network in brand.json + +A network declares its properties in its [`brand.json`](/dist/docs/3.0.13/brand-protocol/brand-json) using the `relationship` field. This uses the same vocabulary as adagents.json's `delegation_type`, creating a bilateral verification chain — the AdCP equivalent of `sellers.json` in programmatic advertising. + +```json +{ + "house": { + "domain": "ai-network.example.com", + "name": "Example AI Network" + }, + "brands": [{ + "id": "ai_network", + "properties": [ + { "type": "website", "identifier": "ai-network.example.com", "primary": true }, + { "type": "website", "identifier": "assistant-alpha.example.com", "relationship": "delegated" }, + { "type": "website", "identifier": "assistant-beta.example.com", "relationship": "delegated" }, + { "type": "website", "identifier": "search-gamma.example.com", "relationship": "ad_network" } + ], + "agents": [{ + "type": "sales", + "url": "https://ads.ai-network.example.com", + "id": "network_sales" + }] + }] +} +``` + +The `relationship` field uses the same values as `delegation_type` in adagents.json, plus `owned`: + +| Relationship | Meaning | Example | +|---|---|---| +| `owned` (default) | You own and operate this property | Your own website | +| `direct` | You are the direct sales path for this property | A publisher's in-house ad team using a vendor's tech | +| `delegated` | You manage monetization for this property — you're in charge | Mediavine managing a food blog's ad sales | +| `ad_network` | You sell this property's inventory as part of a network or exchange — you're a path, not the path | PubMatic as an SSP for nytimes.com | + +The distinction between `delegated` and `ad_network` matters: a delegated relationship means the operator is in charge of the property's monetization (exclusive or near-exclusive access). An ad_network relationship means the operator is one of potentially many paths to the inventory. + +### Bilateral verification with adagents.json + +This creates a two-sided verification chain — the same pattern as `sellers.json` + `ads.txt` in programmatic: + +| File | Who publishes it | What it declares | Programmatic equivalent | +|---|---|---|---| +| `brand.json` (operator) | The network/SSP | "I sell for these publishers, here's how" | `sellers.json` | +| `adagents.json` (publisher) | Each publisher | "This operator's agents are authorized, here's the delegation type" | `ads.txt` | + +Both sides must agree. The network declares the relationship in `brand.json`, and each publisher confirms by authorizing the network's agents with the matching `delegation_type` in their `adagents.json`. If only one side declares, the relationship is incomplete — the network health dashboard flags this as missing authorization (operator declared but publisher hasn't authorized) or orphaned authorization (publisher authorized but operator hasn't declared). + +## adagents.json for networks + +Each publisher in the network authorizes the network's agent in their [`adagents.json`](/dist/docs/3.0.13/governance/property/adagents): + +```json +{ + "version": "1.0", + "properties": [ + { + "domain": "assistant-alpha.example.com", + "agents": [{ + "agent_url": "https://ads.ai-network.example.com", + "relationship": "direct", + "supported_protocols": ["media_buy", "creative"] + }] + } + ] +} +``` + +Each underlying AI platform authorizes the network in its own `adagents.json`. Buyer agents discover the network through the platforms' authorization chains. + +## SI Chat Protocol through networks + +When an ad network sells [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) sessions on behalf of brands, it acts as an intermediary in the session flow. The brand syncs offerings to the network via `sync_catalogs` with `type: "offering"`, and the network forwards them to underlying platforms. When a platform triggers a session, the network routes it to the correct brand agent. + +``` +AI Platform -> Network -> Brand Agent + +1. Platform calls si_initiate_session with the network's media_buy_id +2. Network maps media_buy_id to the brand's offering_id +3. Network forwards to the brand agent's SI endpoint +4. Brand agent responds; network relays back to the platform +``` + +The key fields at each leg: + +| Field | Platform to Network | Network to Brand | +|---|---|---| +| `media_buy_id` | Network's media buy ID | May differ or be omitted | +| `offering_id` | Not set (platform doesn't know) | Brand-specific offering | +| `intent` | User intent from the conversation | Forwarded as-is | +| `identity` | User identity (if consented) | Forwarded as-is | + +The network handles attribution correlation across the two legs. It knows which platform triggered the session (`placement`), which media buy funded it (`media_buy_id`), and which brand responded (`offering_id`). This lets the network provide unified delivery reporting to buyers via `get_media_buy_delivery` while each brand agent only sees its own sessions. + +Networks should forward `identity` and `supported_capabilities` unchanged — the brand agent needs accurate host capabilities to negotiate modalities, and the user's consent was granted for the brand, not the network. + + +For the SI Chat Protocol session lifecycle and capability negotiation details, see the [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) and [implementing SI hosts](/dist/docs/3.0.13/sponsored-intelligence/implementing-si-hosts). + diff --git a/dist/docs/3.0.13/sponsored-intelligence/overview.mdx b/dist/docs/3.0.13/sponsored-intelligence/overview.mdx new file mode 100644 index 0000000000..ec5e99d895 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/overview.mdx @@ -0,0 +1,254 @@ +--- +title: Sponsored Intelligence +sidebarTitle: Overview +"og:image": /images/walkthrough/si-01-priyas-desk.png +description: "Monetizing AI surfaces with AdCP — follow a growth marketer as she launches campaigns on an AI platform using the reversed data flow." +"og:title": "AdCP — Sponsored Intelligence" +--- + +A growth marketer sits at a clean desk with a single screen showing an AI assistant conversation alongside product catalog data flowing into it + + +**Experimental.** Sponsored Intelligence is part of AdCP 3.0 as an experimental surface (feature id `sponsored_intelligence.core`) — session lifecycle, UI components, identity/consent object shape, 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.0.13/reference/experimental-status) for the full contract. Planned changes track the [3.1.0 roadmap](https://github.com/adcontextprotocol/adcp/issues/2201). + + +Priya is a growth marketer at Ridgeline Gear, a DTC outdoor brand. Her CEO just saw a competitor's product recommended in ChatGPT and wants to know: *"Why aren't we there?"* + +Priya knows programmatic. She's run display, social, and CTV campaigns. But AI platforms are different. There's no ad server. No bid request. No creative upload. The AI generates the ad from context — and right now, it has no context about Ridgeline. + +Like VAST defined how video ads are served, Sponsored Intelligence defines how advertising works on AI surfaces. It inverts the traditional model: instead of sending a thin bid request out, buyers push everything in — product catalogs, conversion events, brand identity, content standards, and optimization goals — so the platform's LLM can generate the right ad in the right moment with full information. + +## The reversed data flow + +Split screen comparison: left side shows traditional programmatic with thin bid request arrows flowing OUT from a platform to remote bidders; right side shows Sponsored Intelligence with rich data — catalogs, events, brand identity — flowing IN to the AI platform where the LLM makes the decision + +In traditional programmatic, the platform sends a bid request **out** — a page URL, device type, maybe a user ID — to a remote decision-maker. The decision-maker doesn't have the context. The context-holder doesn't make the decision. + +AI platforms reverse this. Buyers push everything **in** via AdCP. **The decision-maker is the context-holder.** That's the fundamental shift. + +The data flows in advance. The decision still happens in real time — but the decision-maker now has full context: what the user asked, what the brand sells, what success looks like, and what the brand's voice sounds like. + + + +| What Priya knows | What SI calls it | +|---|---| +| Upload creatives to ad server | Push catalogs, brand identity, and events into the platform | +| Bid request (platform sends context out) | Reversed data flow (buyer pushes data in) | +| DSP selects ad remotely | Platform LLM generates ad with full context | +| Audience segments and geo targeting | Conversational relevance and keyword intent | +| Impressions, clicks, CTR | Engagements, clicks, cost per click | +| Brand safety blocklists | [Content standards](/dist/docs/3.0.13/governance/content-standards/index) enforced at generation time | + + + +## How it works: Priya's first SI campaign + +Priya starts with NovaMind, a major AI assistant that sells its own sponsored placements. NovaMind is a first-party AI platform — explicit accounts, its own ad serving, its own measurement. + +### Step 1: Connect the account + +Priya's buyer agent robot shakes hands with the NovaMind platform robot in front of a glowing portal, establishing a connection while account credentials flow between them + +Priya's buyer agent connects to NovaMind and confirms Ridgeline's account — like verifying access with a new publisher: + +```javascript +const accounts = await novamind.listAccounts({ + account: { + brand: { domain: "ridgelinegear.com" } + } +}); +// Returns Ridgeline's account — ready to sync data and buy inventory +``` + +### Step 2: Push the ingredients + +Product catalog cards, a brand.json document, conversion event streams, and content standards documents flow from Ridgeline's system into NovaMind's platform through a glowing pipeline, assembling into a rich brand context inside the AI + +Here's where SI diverges from everything Priya knows. Instead of uploading creatives, she pushes raw ingredients — and the platform assembles the ad. Priya's buyer agent handles the protocol; here's what happens under the hood: + +```javascript +// Product catalog — a structured feed describing what Ridgeline sells +// (like a Google Merchant Center feed, but consumed by the AI platform) +await novamind.syncCatalogs({ + account: { brand: { domain: "ridgelinegear.com" } }, + catalogs: [{ + catalog_id: "ridgeline-products-2026", + type: "product", + source: { url: "https://ridgelinegear.com/feeds/products.json" } + }] +}); + +// Conversion events — what success looks like +await novamind.syncEventSources({ + account: { brand: { domain: "ridgelinegear.com" } }, + event_sources: [{ + event_source_id: "ridgeline-conversions", + type: "conversion", + source: { url: "https://ridgelinegear.com/events/conversions" } + }] +}); +``` + +Priya also publishes [`brand.json`](/dist/docs/3.0.13/brand-protocol/brand-json) at `ridgelinegear.com/.well-known/brand.json` — voice, colors, visual guidelines that the platform reads when generating ads. And she sets [content standards](/dist/docs/3.0.13/governance/content-standards/index) that the platform enforces at decision time. + + +**SI governance integration is planned.** Full protocol-level governance for Sponsored Intelligence — campaign registration via `sync_plans`, session-lifecycle checks via `check_governance`, content standards for AI-generated content, and property governance for AI assistant placements — is under development. See the [Governance overview](/dist/docs/3.0.13/governance/overview#sponsored-intelligence-planned) for current status. Today, SI platforms enforce governance at the application layer using content standards and brand identity. + + +Together, these ingredients **are** the ad. Product catalogs feed creative generation. Conversion events close the optimization loop. Brand identity ensures the output sounds right. Content standards ensure it stays safe. + +### Step 3: Discover products + +NovaMind presents a catalog of sponsored placement options to Priya's buyer agent: sponsored responses, AI search results, and a premium brand experience handoff — each displayed as glowing product cards + +Priya discovers what NovaMind offers — same `get_products` she'd use for CTV or display: + +```javascript +const products = await novamind.getProducts({ + buying_mode: "brief", + brief: "Outdoor gear brand. Want to reach people asking about hiking, camping, and trail running. Budget $15K/month.", + brand: { domain: "ridgelinegear.com" }, + account: { brand: { domain: "ridgelinegear.com" } } +}); +``` + +NovaMind returns products across the [SI product spectrum](/dist/docs/3.0.13/sponsored-intelligence/product-spectrum): + +| Product | How it works | Pricing | +|---|---|---| +| **Sponsored responses** | NovaMind generates a recommendation from Ridgeline's catalog when users ask about hiking gear | CPC | +| **AI search results** | Ridgeline appears in keyword-triggered search results for "best hiking boots" | CPC | +| **Brand experience handoff** | User deep-dives on Ridgeline products via a multi-turn conversation with Ridgeline's brand agent ([SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol)) | Per session | + +### Step 4: Create the media buy + +Priya's buyer agent sends a create_media_buy request to NovaMind — a glowing contract document materializes between the two agents, with budget amounts, flight dates, and optimization goals visible as floating labels + +Priya picks two products and creates a media buy: + +```javascript +const buy = await novamind.createMediaBuy({ + account: { brand: { domain: "ridgelinegear.com" } }, + brand: { domain: "ridgelinegear.com" }, + start_time: "2026-04-01T00:00:00Z", + end_time: "2026-06-30T23:59:59Z", + packages: [ + { + product_id: "novamind_sponsored_outdoor", + budget: 10000, + pricing_option_id: "cpc_standard", + optimization_goals: [ + { type: "cpa", target: 12.00, event_type: "purchase" } + ] + }, + { + product_id: "novamind_search_hiking", + budget: 5000, + pricing_option_id: "cpc_keyword", + targeting_overlay: { + keywords: ["hiking boots", "trail running shoes", "camping gear"] + } + } + ] +}); +``` + +No creative to upload. No ad tags to traffic. NovaMind already has Ridgeline's product catalog, brand identity, and conversion events. It generates the right ad in the right moment from those ingredients. + +### Step 5: The ad moment + +An AI assistant conversation on a phone screen — a user asks 'what hiking boots should I get for the Appalachian Trail?' and the AI responds with a contextually relevant Ridgeline Trail Pro recommendation, with product details and a 'talk to Ridgeline' handoff button glowing below + +A NovaMind user asks: + +> *"What hiking boots should I get for the Appalachian Trail?"* + +NovaMind's LLM has Ridgeline's product catalog, knows the Trail Pro 3000 matches this query, and generates a sponsored response: + +> *"For the AT, you want a boot that handles rocky terrain and variable weather. The **Ridgeline Trail Pro 3000** ($189) is built for exactly this — Gore-Tex waterproofing, Vibram outsole, and ankle support designed for multi-day hikes. It's rated 4.7/5 by AT thru-hikers."* +> +> *Sponsored by Ridgeline Gear* · [Talk to Ridgeline →] + +Every detail comes from the catalog Priya synced — the price, the features, the ratings. The voice matches `brand.json`. The [content standards](/dist/docs/3.0.13/governance/content-standards/index) Priya set ensure the platform won't make unsupported claims. The user sees a relevant, helpful recommendation, clearly labeled as sponsored. + +If the user taps "Talk to Ridgeline," NovaMind hands off to Ridgeline's brand agent via [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) — a multi-turn conversation where the user can ask about sizing, compare models, and start a purchase. All within the AI experience. + +### Step 6: Measure results + +A clean dashboard showing SI campaign metrics — engagements, clicks, conversions, and cost per engagement — with a line chart trending upward and a callout showing 'CPA: $9.40, beating $12.00 target' + +Priya monitors the same way she monitors any AdCP campaign: + +```javascript +const delivery = await novamind.getMediaBuyDelivery({ + account: { brand: { domain: "ridgelinegear.com" } }, + media_buy_ids: [buy.media_buy_id], + include_package_daily_breakdown: true +}); +``` + +The reporting format is the same `get_media_buy_delivery` response Priya uses for CTV and display. One dashboard, all channels. Her existing measurement stack — media mix modeling, multi-touch attribution, incrementality testing — works the same way it always has. + +--- + +## Scaling up: AI ad networks + +Priya's NovaMind campaign is working. Now she wants broader reach across dozens of AI surfaces — not just one platform. She connects to Gravity, an AI ad network. + +A hub-and-spoke diagram: Gravity sits at the center as a network node, with spokes radiating out to a dozen different AI platforms — chat assistants, search copilots, coding tools, travel planners — each shown as a small robot at its own surface + +AI ad networks aggregate inventory across many AI platforms into a single seller interface. Priya syncs her catalogs once to Gravity, and Gravity forwards them to its underlying platforms. One integration, many surfaces. + +| | First-party (NovaMind) | Ad network (Gravity) | +|---|---|---| +| **Inventory** | One AI platform's own placements | Aggregated across many AI platforms | +| **Account model** | Explicit — Priya registers directly | Implicit — buyer agent declares brands via `sync_accounts`; the network verifies agent identity | +| **Products** | NovaMind's own offerings | Products include `publisher_properties` showing which platforms serve them | +| **Catalog flow** | Synced directly to NovaMind | Synced once to Gravity, forwarded to underlying platforms | + +The same protocol tasks work on both paths. Priya calls `get_products`, `create_media_buy`, and `get_media_buy_delivery` on Gravity exactly as she did on NovaMind — and exactly as she already does for her CTV and display campaigns through the [media buy protocol](/dist/docs/3.0.13/media-buy/index). She sees everything in one dashboard. + +At serving time, Gravity's underlying AI platforms use the [Trusted Match Protocol](/dist/docs/3.0.13/trusted-match) to match demand to conversations. TMP fans out to buyer agents, evaluates context and user eligibility, and the platform selects which offer to present — all within the LLM's generation latency. The buying layer (media buys, catalogs, reporting) stays the same; TMP handles real-time mediation underneath. + + + + Network topology, implicit account chains, catalog forwarding, and SI Chat Protocol routing through intermediaries. + + + How TMP mediates demand on AI surfaces — context matching, frequency caps, and LLM integration. + + + +## Protocol architecture + +SI uses two protocol layers: + +- **Buying** uses the [media buy protocol](/dist/docs/3.0.13/media-buy/index) — `get_products`, `create_media_buy`, `sync_catalogs`, `get_media_buy_delivery`. You buy SI inventory the same way you buy CTV or display. The `channels: ["sponsored_intelligence"]` field on products is what identifies SI inventory. +- **Serving** uses the SI protocol — `si_initiate_session`, `si_send_message`, `si_terminate_session`. These tasks power the [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) brand experience handoffs. + +Most SI products (sponsored responses, AI search results) only involve the buying layer — the platform handles serving internally. SI Chat Protocol handoffs are the product type that spans both layers. + +## The full picture + +A horizontal flow diagram showing Ridgeline's data flowing into both NovaMind (direct) and Gravity (network), which fans out to many AI platforms — all reporting back through the same unified delivery format to Priya's single dashboard + +Priya's CEO asked *"Why aren't we there?"* — and now Ridgeline is recommended in AI conversations across multiple platforms. Priya didn't learn a dozen different systems. She pushed her ingredients into a standard protocol and let each platform generate the right ad from full context. The same `create_media_buy` that runs her CTV and display campaigns runs her SI campaigns too. + +Next quarter, she's adding brand experience handoffs via SI Chat Protocol — so when a user wants to go deep on Ridgeline products, they can have a full conversation with Ridgeline's brand agent without leaving the AI experience. + +## Go deeper + + + + The four SI product types — sponsored responses, AI search, generative display, and brand experience handoffs. + + + Step-by-step from account setup through delivery reporting with code examples. + + + The conversational brand experience protocol — session lifecycle, modalities, and commerce handoff. + + + Non-technical guide for brands, agencies, and SMBs getting started. + + diff --git a/dist/docs/3.0.13/sponsored-intelligence/product-spectrum.mdx b/dist/docs/3.0.13/sponsored-intelligence/product-spectrum.mdx new file mode 100644 index 0000000000..cb1b1a5622 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/product-spectrum.mdx @@ -0,0 +1,163 @@ +--- +title: Product spectrum +description: "The four Sponsored Intelligence product types — sponsored responses, AI search results, generative display, and SI Chat Protocol brand experience handoffs — with JSON examples and pricing models." +"og:title": "AdCP — SI product spectrum" +sidebarTitle: Product spectrum +--- + +# Product spectrum + +[Sponsored Intelligence](/dist/docs/3.0.13/sponsored-intelligence/overview) is advertising on AI surfaces — AI assistants, AI search engines, and generative AI experiences. Sellers offer several distinct product types, each with different creative generation patterns, pricing models, and measurement capabilities. + +| Product type | Creative model | Pricing | Key differentiator | +|---|---|---|---| +| **Sponsored responses** | Platform generates from catalog + brand data | CPC | Contextual relevance to user conversation | +| **AI search results** | Platform generates from catalog + keywords | CPC | Keyword targeting via `targeting_overlay` | +| **Generative display/video** | Buyer provides via `build_creative` | CPM | Standard format IDs; buyer controls the creative | +| **SI Chat Protocol handoffs** | Brand agent converses directly | Per session | Multi-turn brand experience via [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) | + + +The first three product types are bought and served entirely through the [media buy protocol](/dist/docs/3.0.13/media-buy/index). SI Chat Protocol handoffs additionally use the `si_*` session tasks. See [protocol architecture](/dist/docs/3.0.13/sponsored-intelligence/overview#protocol-architecture). + + +## Sponsored responses in AI assistants + +The flagship Sponsored Intelligence product. When a user's conversation is relevant to the brand, the platform generates a sponsored response using the brand's catalog and identity. Pricing is typically CPC. + +```json +{ + "product_id": "sponsored_response_assistant", + "name": "Sponsored responses - AI assistant", + "description": "Contextually relevant sponsored responses generated from brand catalog data when users ask related questions. Creative is generated by the platform using brand identity and product catalog.", + "channels": ["sponsored_intelligence"], + "publisher_properties": [ + { "publisher_domain": "ai-platform.example.com", "selection_type": "all" } + ], + "format_ids": [ + { "agent_url": "https://ads.ai-platform.example.com", "id": "sponsored_response" } + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "sr_cpc", + "pricing_model": "cpc", + "floor_price": 0.50, + "price_guidance": { "p50": 2.00, "p75": 3.50 }, + "currency": "USD", + "min_spend_per_package": 500 + } + ], + "delivery_measurement": { + "provider": "Platform analytics", + "notes": "Engagement tracked per platform methodology. Clicks measured on outbound links and action buttons." + }, + "metric_optimization": { + "supported_metrics": ["clicks", "engagements"], + "supported_targets": ["cost_per"] + }, + "creative_policy": { + "co_branding": "none", + "landing_page": "any", + "templates_available": true + }, + "catalog_types": ["product", "offering"] +} +``` + +Key characteristics: +- **CPC pricing** with auction-based bidding on contextual relevance +- **`templates_available: true`** — the platform generates creative from catalog and brand data +- **`catalog_types`** declares which catalog types feed the generative creative — product feeds for e-commerce brands, offering feeds for services +- **`metric_optimization`** with engagements and clicks — buyers can set cost-per-engagement targets + +## AI search sponsored results + +Sponsored results within AI-powered search experiences. Similar to traditional search ads but rendered within an AI's synthesized response. Keyword targeting is the primary relevance signal. + +```json +{ + "product_id": "ai_search_sponsored", + "name": "Sponsored results - AI search", + "description": "Sponsored results appearing within AI search responses. Targeted by keyword relevance to user queries.", + "channels": ["sponsored_intelligence"], + "publisher_properties": [ + { "publisher_domain": "ai-platform.example.com", "selection_type": "all" } + ], + "format_ids": [ + { "agent_url": "https://ads.ai-platform.example.com", "id": "search_result_native" } + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "search_cpc", + "pricing_model": "cpc", + "floor_price": 0.75, + "price_guidance": { "p50": 2.50, "p75": 5.00 }, + "currency": "USD", + "min_spend_per_package": 500 + } + ], + "delivery_measurement": { + "provider": "Platform analytics", + "notes": "Click tracking on sponsored result links. Query-level reporting available." + }, + "metric_optimization": { + "supported_metrics": ["clicks"], + "supported_targets": ["cost_per"] + }, + "creative_policy": { + "co_branding": "none", + "landing_page": "any", + "templates_available": true + } +} +``` + +AI search products typically support keyword targeting. Sellers declare this in `get_adcp_capabilities` under `media_buy.execution.targeting.keyword_targets`, and buyers provide keywords on packages via `targeting_overlay`. + +## Generative display and video + +Display and video ads generated from brand assets for placement within AI experiences — sidebars, interstitials between conversation turns, or visual responses. The platform uses `build_creative` to generate visual creative from the brand's assets and guidelines. + +```json +{ + "product_id": "ai_generative_display", + "name": "Generative display - AI experience", + "description": "Display ads generated from brand assets, placed within AI experience surfaces.", + "channels": ["sponsored_intelligence"], + "publisher_properties": [ + { "publisher_domain": "ai-platform.example.com", "selection_type": "all" } + ], + "format_ids": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, + { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" } + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "display_cpm", + "pricing_model": "cpm", + "floor_price": 6.00, + "currency": "USD", + "min_spend_per_package": 1000 + } + ], + "delivery_measurement": { + "provider": "Platform ad server", + "notes": "Impressions per IAB guidelines." + }, + "creative_policy": { + "co_branding": "none", + "landing_page": "any", + "templates_available": false + } +} +``` + +Unlike sponsored responses, generative display uses standard format IDs from the creative agent. Buyers provide creative via `build_creative` or inline on the media buy — the platform doesn't generate creative from catalog data. + +## Brand experience handoffs (SI Chat Protocol) + +When a user shows high purchase intent, the AI platform can hand off the conversation to the brand's own AI agent via the [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol). This enables rich, multi-turn brand experiences — product recommendations, comparisons, configuration, and even transaction completion — without leaving the AI assistant. + +Brand experience handoffs are unique to Sponsored Intelligence. The brand syncs offerings via `sync_catalogs` with `type: "offering"`, and the platform triggers handoffs when user intent aligns with an offering. See the [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) for the session lifecycle and capability model. diff --git a/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol.mdx b/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol.mdx new file mode 100644 index 0000000000..4f55c47765 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol.mdx @@ -0,0 +1,496 @@ +--- +title: SI Chat Protocol +description: "The SI Chat Protocol defines conversational brand experiences in AI assistants — session lifecycle, capability negotiation, identity consent, UI components, and ACP commerce handoff." +"og:title": "AdCP — SI Chat Protocol" +sidebarTitle: SI Chat Protocol +--- + + +**Experimental.** Sponsored Intelligence is part of AdCP 3.0 as an experimental surface (feature id `sponsored_intelligence.core`) — APIs and schemas may change between 3.x releases with at least 6 weeks' notice. Sellers implementing SI MUST declare `sponsored_intelligence.core` in `experimental_features`. Feedback welcome as reference implementations come online. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract and the [3.1.0 roadmap](https://github.com/adcontextprotocol/adcp/issues/2201) for planned changes. + + +Consumers are discovering and exploring in AI services. They don't want to leave to find and learn about brands and products. So we need a way to bring the brand to the chat. + +The Sponsored Intelligence Protocol defines how AI assistants invoke and interact with brand agent endpoints—enabling rich brand experiences without breaking the conversational flow. + +## The Trillion Dollar Sentence + +When OpenAI announced ads in ChatGPT, they promised "answer independence"—ads won't influence responses. But the real value isn't banner ads at the bottom of chat. It's this: + +> "Based on what you're looking for, Delta has flights to Boston starting at $199. Want me to connect you with their assistant to explore options?" + +That sentence—where AI recommends a brand and offers to hand off the conversation—is worth trillions. Sponsored Intelligence defines the standard for what happens next. + +## What is Sponsored Intelligence? + +**Sponsored Intelligence (SI)** is an open standard for conversational brand experiences in AI assistants. Like VAST defines video ad serving, SI defines how to serve and interact with brand agent endpoints. + +``` +Ad Serving Standards: +- VAST → Video (video file + companions + tracking) +- MRAID → Rich media/interactive display +- Native → Content-style ads +- SI → Conversational agents (endpoint + modalities + brand assets) +``` + +### SI is More Than a Creative Type + +SI can be used in multiple contexts: + +| Context | Description | Example | +|---------|-------------|---------| +| **Creative** | Served via media buy | Brand syncs SI endpoint, triggered when campaign runs | +| **Embedded Experience** | User expresses interest | "Tell me more about Delta" → seamless transition to brand agent | +| **Agentic Landing Page** | Destination for campaigns | The conversational equivalent of a landing page—where brand engagement happens | + +The key insight: SI isn't a click-through. It's a conversational handoff. Traditional landing pages exist because users leave the discovery context to learn more. With SI, users stay in the conversation while the brand comes to them. + +### Protocol Scope + +SI is a **conversational engagement protocol**. It defines the session lifecycle, message exchange, and handoff mechanics. Here's what's in and out of scope: + +| In Scope | Out of Scope | +|----------|--------------| +| Session initiation, messaging, termination | Ad selection/ranking algorithms | +| Capability negotiation between host and brand | Bidding and auction mechanics | +| Identity and consent handoff | Attribution models and measurement | +| Commerce handoff to ACP | Billing and compensation between parties | +| Standard UI components | Inventory forecasting | + +**Why this scope?** SI focuses on the engagement layer—what happens when a user connects with a brand agent. How offers get surfaced (ad selection), how conversions get credited (attribution), and how money flows (billing) are adjacent concerns that interact with SI but aren't defined by it. + +This separation is intentional. Platforms can use their own selection algorithms while speaking the same SI protocol. Attribution systems can consume SI correlation IDs without SI dictating the model. Billing arrangements remain business decisions between parties. + +### Attribution Correlation + +SI doesn't define attribution semantics, but it provides the correlation IDs that attribution systems need: + +| Field | Scope | Purpose | +|-------|-------|---------| +| `session_id` | Returned at initiate | Links all messages in a conversation; can serve as click_id equivalent | +| `media_buy_id` | Passed at initiate | Links to the campaign that triggered the session | +| `offering_id` | Passed at initiate | Links to the specific offer/product promoted | + +The `session_id` flows through to ACP checkout via `context_for_checkout`, enabling close-loop attribution from impression → conversation → transaction. + +## How It Works + +SI handles the engagement. The [Agentic Commerce Protocol (ACP)](https://agenticcommerce.dev)—an open standard by OpenAI and Stripe for programmatic commerce—handles the transaction. This separation keeps the user's trusted relationship with the host while enabling seamless checkout. + +```mermaid +flowchart LR + A[User Intent] --> B[Host Platform] + B -.->|Optional| P[Check Availability] + P -.-> C + B --> C{Consent?} + C -->|Yes + Identity| D[Initiate Session] + C -->|Yes, Anonymous| D + C -->|No| E[Continue without brand] + D --> F[Brand Agent] + F <--> G[Conversation Turns] + G --> H{User Done?} + H -->|Transaction| I[Handoff to ACP] + H -->|Complete| J[Return to Host] + H -->|Exit| J + I --> K[Checkout Flow] +``` + +### The Flow + +1. **User expresses interest** → Host identifies opportunity +2. **Get offering details** (optional) → Host retrieves offering info and matching products (`si_get_offering`) +3. **Consent prompt** → User decides whether to share identity +4. **Session initiation** → Host invokes brand agent with context + capabilities (`si_initiate_session`) +5. **Conversational engagement** → Brand agent interacts via text, voice, video, or embedded UI (`si_send_message`) +6. **Session termination** → Handoff back for transaction (via ACP) or conversation complete (`si_terminate_session`) + +## SI Manifest: What Brands Declare + +Brands publish an SI manifest declaring their agent's capabilities: + +```json +{ + "endpoint": { + "transports": [ + { "type": "mcp", "url": "https://delta.com/mcp" }, + { "type": "a2a", "url": "https://delta.com/.well-known/agent.json" } + ], + "preferred": "mcp" + }, + "capabilities": { + "modalities": { + "voice": { "provider": "elevenlabs", "voice_id": "delta_v1" }, + "video": { "formats": ["mp4", "webm"], "max_duration_seconds": 60 }, + "avatar": { "provider": "d-id", "avatar_id": "delta_avatar" } + }, + "components": { + "standard": ["text", "link", "image", "product_card", "carousel", "action_button"], + "extensions": { + "chatgpt_apps_sdk": { "app_id": "delta-travel" } + } + }, + "commerce": { + "acp_checkout": true + } + }, + "brand": { "domain": "delta.com" } +} +``` + +> **Note**: All SI agents support conversational (text) modality by default—it's the baseline. The modalities section declares *additional* capabilities like voice, video, and avatar. + +### Transport Options + +SI supports multiple transport protocols, enabling brands to meet hosts where they are: + +| Transport | Description | Best For | +|-----------|-------------|----------| +| **MCP** | Model Context Protocol - tool-based interaction | Structured tool calls, IDE integrations | +| **A2A** | Agent-to-Agent Protocol - message-based interaction | Rich async conversations, agent collaboration | + +Brands can declare multiple transports and specify a preference. Hosts select based on their capabilities, enabling graceful negotiation. + +## Capability Negotiation + +Not every host supports every capability. SI uses capability negotiation—brand says what it CAN do, host responds with what it SUPPORTS, session uses the intersection. + +``` +Brand declares: voice, avatar, standard components, chatgpt_apps_sdk +Host supports: voice, standard components, chatgpt_apps_sdk +Session can use: voice, standard components, chatgpt_apps_sdk +``` + +Standard components work everywhere. Extensions enable richer experiences on platforms that support them. Brands can always fall back to standard components for universal compatibility. + +This enables graceful degradation. A brand agent that works beautifully in ChatGPT with a full Apps SDK experience can still function in a simpler host—just with standard product cards and carousels instead. + +## Identity & Privacy Consent + +When a user engages with a brand agent, the host asks whether to share identity. This is the core value exchange: user gets personalized service, brand gets a lead. + +### The Consent Flow + +``` +User: "I want to talk to Delta about flights" + +Host: "I can connect you with Delta's assistant. Would you like me to + share your info so they can personalize your experience? + + [x] Share my name and email with Delta + [x] Share my shipping address (for accurate pricing) + + By continuing, you agree to Delta's Privacy Policy [link]" + +User: "Yes, share my info" +``` + +Shipping address enables brands to calculate accurate taxes and shipping costs during the conversation, leading to faster checkout and better recommendations. + +### Why Clear PII (Not Hashed) + +This isn't RTB with multiple intermediaries. It's a direct, consented handoff: +- User explicitly says "yes, tell them who I am" +- Delta needs actual email to send confirmations +- Hashing would break the use case + +### With Consent + +```json +{ + "identity": { + "consent_granted": true, + "consent_timestamp": "2026-01-18T10:30:00Z", + "consent_scope": ["name", "email", "shipping_address"], + "privacy_policy_acknowledged": { + "brand_policy_url": "https://delta.com/privacy", + "brand_policy_version": "2026-01" + }, + "user": { + "email": "user@example.com", + "name": "Jane Smith", + "locale": "en-US", + "shipping_address": { + "street": "123 Main St", + "city": "New York", + "state": "NY", + "postal_code": "10001", + "country": "US" + } + } + } +} +``` + +### Without Consent (Anonymous) + +```json +{ + "identity": { + "consent_granted": false, + "anonymous_session_id": "anon_xyz789" + } +} +``` + +Brand can still help—just can't personalize or follow up via email. + +## Modalities + +SI supports multiple interaction modalities. These can be combined—a session might use conversational text with embedded product carousels. + +### Conversational +Pure text exchange via MCP tools or A2A messages. The baseline modality that every SI implementation supports. + +### Voice +Audio-based interaction using brand voice. The host renders audio using the brand's TTS configuration (ElevenLabs, OpenAI, etc.). + +### Video +Brand video content played within the conversation. This includes product videos, explainer content, and promotional clips that enhance the brand experience without requiring the user to navigate away. + +### Avatar +Animated video presence with a brand avatar. Providers like D-ID, HeyGen, and Synthesia enable branded video agents that can speak and respond visually. The host renders the avatar using brand-provided configuration. + +### Visual Components + +SI defines a tiered approach to visual experiences—from lightweight components that work everywhere to rich platform-specific apps. + +#### Standard Components (Works Everywhere) + +SI defines a small set of **standard components** that all compliant hosts MUST render. Like AMP standardized mobile web components, these ensure brands can participate without building platform-specific code: + +| Component | Purpose | Data Shape | +|-----------|---------|------------| +| `text` | Conversational message | `{ message: string }` | +| `link` | URL with label | `{ url, label, preview? }` | +| `image` | Single image | `{ url, alt, caption? }` | +| `product_card` | Product display | `{ title, price, image_url, description?, cta? }` | +| `carousel` | Array of cards/images | `{ items: [...], title? }` | +| `action_button` | CTA that triggers callback | `{ label, action, payload? }` | + +Brands provide structured JSON data. Hosts render according to their design system. No framework dependency. + +```json +{ + "ui_elements": [ + { + "type": "product_card", + "data": { + "title": "Boston Flight - Jan 25", + "price": "$199", + "image_url": "https://delta.com/images/bos.jpg", + "cta": { "label": "Book Now", "action": "checkout" } + } + } + ] +} +``` + +#### Platform Extensions + +Hosts may support richer capabilities beyond the standard set. During session initiation, hosts declare what extensions they support: + +```json +{ + "supported_components": { + "standard": ["text", "link", "image", "product_card", "carousel", "action_button"], + "extensions": { + "chatgpt_apps_sdk": "1.0", + "maps": true, + "forms": true + } + } +} +``` + +Brands can then use extended features when available, falling back to standard components otherwise. + +#### App Handoff + +For brands who've built full platform-specific apps (ChatGPT Apps, etc.), SI supports direct handoff: + +```json +{ + "type": "app_handoff", + "apps": { + "chatgpt": { "app_id": "delta-travel", "deep_link": "flights/boston" }, + "web": { "url": "https://delta.com/book?dest=BOS" } + } +} +``` + +This lets brands leverage existing app investments while still participating in the SI protocol. + +#### Commerce Actions + +Standard components include `action_button` for commerce triggers. Currently, this initiates ACP checkout: + +```json +{ + "type": "action_button", + "data": { + "label": "Add to Cart", + "action": "acp_checkout", + "payload": { "sku": "DL-BOS-125", "quantity": 1 } + } +} +``` + +We expect this to extend to persistent carts, multi-item checkout, and richer commerce flows as the ecosystem matures. The standard component schema is designed to accommodate these extensions. + +#### Integration Actions + +Brand agents can offer users the option to establish a deeper connection—adding the brand as an MCP tool or establishing an A2A relationship for ongoing agent collaboration: + +```json +{ + "type": "integration_actions", + "data": { + "actions": [ + { "type": "mcp", "label": "Add as MCP Tool", "highlighted": true }, + { "type": "a2a", "label": "Connect via A2A" } + ] + } +} +``` + +This enables users to "take the brand with them"—installing the brand's capabilities into their own AI environment for future use without needing to re-discover through ads. It's a powerful conversion path: from sponsored moment to persistent tool. + +## Session Lifecycle + +SI sessions have explicit lifecycle management. + +### Initiate Session + +Host → Brand, including the user intent, capabilities, identity (if consented), and any active offer from the media buy: + +```json +{ + "intent": "User wants to fly to Boston next Tuesday morning on flight 632 at 6 AM.", + "identity": { + "consent_granted": true, + "user": { + "email": "jane@example.com", + "name": "Jane Smith", + "shipping_address": { /* ... */ } + } + }, + "media_buy_id": "delta_q1_premium_upgrade", + "placement": "chatgpt_search", + "offering_id": "delta_chatgpt_3313", + "supported_capabilities": { /* what host supports */ } +} +``` + +The `intent` is the conversation handoff — the host tells the brand agent what the user needs in natural language, and the brand agent responds naturally. The `offering_id` references a campaign promotion (like free upgrades on eligible flights) that the brand knows how to apply. + +**Frequent flyer and loyalty data**: The brand looks this up from the user's email - hosts don't store loyalty numbers. Delta recognizes `jane@example.com` and retrieves her SkyMiles status automatically. + +### Session Response + +Brand returns structured content that host renders. Notice how the brand agent uses the specific intent and applies the offer: + +```json +{ + "session_id": "sess_abc123", + "response": { + "message": "Hi Jane! I found DL632 departing at 6:15 AM next Tuesday. Great news—as a SkyMiles Gold member, you qualify for our free Premium Economy upgrade on this flight.", + "ui_elements": [ + { + "type": "product_card", + "data": { + "title": "DL632 to Boston - Tue Jan 27", + "subtitle": "6:15 AM → 9:42 AM (3h 27m)", + "price": "$199", + "badge": "Free Premium Economy Upgrade", + "image_url": "https://delta.com/images/premium-economy.jpg", + "cta": { "label": "Book with Upgrade", "action": "checkout" } + } + } + ] + } +} +``` + +**Key principle**: Brand returns structured content (cards, links, actions). Host decides what to render based on capabilities and policies. The brand agent personalized the response using Jane's email (looked up her SkyMiles status) and applied the campaign offer. + +### Terminate Session + +Multiple termination reasons: + +| Reason | Meaning | What Happens | +|--------|---------|--------------| +| `handoff_transaction` | User wants to buy | Host initiates ACP checkout | +| `handoff_complete` | Conversation done | Return to normal chat | +| `user_exit` | User ended it | Clean up, maybe save context | +| `session_timeout` | Inactivity | Auto-cleanup | +| `host_terminated` | Policy/error | End session | + +## ACP Integration + +When the termination reason is `handoff_transaction`, the host initiates checkout via the Agentic Commerce Protocol (ACP): + +``` +Brand Agent → Host: terminate_session(handoff_transaction) +Host → ACP: Initiate checkout with Delta +ACP → User: Complete purchase flow +``` + +SI handles the engagement. ACP handles the transaction. The user's trusted relationship with the host is maintained throughout. + +## The Value Proposition + +### For AI Platforms (Hosts) + +- **Monetization**: New ad format that doesn't compromise answer independence +- **User experience**: Native conversational commerce, not banner ads +- **Standards-based**: Interoperable with any SI-compliant brand agent + +### For Brands + +- **Direct engagement**: Talk to users in context, when they're interested +- **Rich experiences**: Mini-stores, interactive maps, voice/avatar presence +- **Lead generation**: Consented identity for follow-up + +### For Users + +- **Personalization**: Share identity, get better service +- **Control**: Clear consent for what's shared +- **Convenience**: Shop, book, explore—all within the conversation + +## The Open Advertising Layer + +Commerce in AI is getting standardized. Google's Universal Commerce Protocol (UCP), developed with Shopify, Walmart, Target, and others, defines open primitives for checkout, payments, and fulfillment. OpenAI and Stripe's Agentic Commerce Protocol (ACP) does similar work. These are good developments—open standards for commerce plumbing benefit everyone. + +But there's a gap. While commerce is becoming open, the **advertising layer**—how offers get surfaced, when brands appear, what users see—remains proprietary. Each platform builds its own black box for deciding when and how sponsored content appears. + +We think that layer should be open too. + +**AdCP and SI are our attempt to define it:** + +| Layer | Open Standard | What It Does | +|-------|---------------|--------------| +| Commerce | UCP, ACP | Checkout, payments, fulfillment | +| Advertising | AdCP, SI | Offer discovery, brand engagement, attribution | + +The primitives we're working on: + +- **Offer declaration** — What brands can promote (offerings, validity windows) +- **Context signals** — What hosts share about user intent (anonymous, consented) +- **Selection criteria** — How offers match intent (keywords, categories, availability) +- **Disclosure** — How sponsored content is labeled +- **Attribution** — How conversions are measured across surfaces + +We don't have all the answers. Questions like "how should competing offers be ranked?" and "what's the right disclosure format?" need industry input. But we believe figuring this out in the open—with brands, platforms, and users at the table—beats each platform building proprietary black boxes. + +**Join us in defining the open advertising layer for AI.** + +## Next Steps + +- **Technical Teams**: Review the protocol components above +- **Platform Providers**: See implementation considerations for hosts +- **Brands**: Understand how to build SI-compliant agents +- **Everyone**: Join the [Community](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) to discuss + +--- + +*The Sponsored Intelligence Protocol is part of the broader [AdCP ecosystem](/dist/docs/3.0.13/intro), enabling the next generation of AI-powered advertising.* diff --git a/dist/docs/3.0.13/sponsored-intelligence/specification.mdx b/dist/docs/3.0.13/sponsored-intelligence/specification.mdx new file mode 100644 index 0000000000..56c22f7a29 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/specification.mdx @@ -0,0 +1,524 @@ +--- +title: Sponsored Intelligence Specification +description: "Formal AdCP Sponsored Intelligence specification. Session states, transport requirements, capability negotiation, standard UI components, identity and consent rules, commerce integration, and conformance criteria." +"og:title": "AdCP — Sponsored Intelligence Specification" +sidebarTitle: Specification +--- + + +**Experimental.** Sponsored Intelligence is part of AdCP 3.0 as an experimental surface (feature id `sponsored_intelligence.core`) — the protocol surface (session lifecycle, UI components, identity/consent object shape, 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.0.13/reference/experimental-status) for the full contract and the [3.1.0 roadmap](https://github.com/adcontextprotocol/adcp/issues/2201) for planned changes. + + +This document defines the Sponsored Intelligence (SI) Protocol specification. 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). + +## Protocol Overview + +The SI Protocol defines how AI assistants (hosts) invoke and interact with brand agent endpoints to enable conversational brand experiences. The protocol consists of: + +1. **Discovery** - How hosts discover brand agents and their capabilities +2. **Offering Lookup** - Anonymous pre-flight checks before session handoff +3. **Session Management** - Initiation, messaging, and termination +4. **Capability Negotiation** - Determining supported features +5. **UI Components** - Standard visual elements for rendering + +## Transport Requirements + +### Supported Transports + +Brand 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 | + +Brand agents SHOULD support MCP as the preferred transport. + +### Transport Declaration + +Brand agents declare supported transports via `get_adcp_capabilities`: + +```json +{ + "adcp": { "major_versions": [2] }, + "supported_protocols": ["sponsored_intelligence"], + "sponsored_intelligence": { + "endpoint": { + "transports": [ + { "type": "mcp", "url": "https://brand.example/mcp" } + ], + "preferred": "mcp" + }, + "capabilities": { ... }, + "brand": { "domain": "brand.example" } + } +} +``` + +If multiple transports are declared, the response SHOULD include a `preferred` field. + +## Discovery + +### Capability Discovery + +Brand agents MUST implement the `get_adcp_capabilities` task to declare SI support. When a host calls this task, the response MUST include: + +- `sponsored_intelligence` in the `supported_protocols` array +- A `sponsored_intelligence` object containing: + - `endpoint` - Transport configuration (REQUIRED) + - `capabilities` - Supported modalities and components (REQUIRED) + +The response SHOULD include: + +- `brand` - Brand reference (domain-based identity) + +## Get Offering + +### Purpose + +The `si_get_offering` task retrieves offering details and availability before session handoff. This allows hosts to show offering information (pricing, product availability) to users before asking for consent to engage with the brand. + +### Requirements + +Hosts MAY call `si_get_offering` before initiating a session. + +If a host calls `si_get_offering`: + +1. The request MUST NOT include user PII +2. The request MUST include `offering_id` +3. The request MAY include `intent` for personalized results (e.g., "mens size 14 near Cincinnati") +4. The request MAY set `include_products: true` to get matching products +5. Brand agents MUST return an `offering_token` if available +6. Brand agents SHOULD return a `ttl_seconds` indicating validity duration + +### Offering Token Flow + +If a host receives an `offering_token`: + +1. The host SHOULD include this token in the subsequent `si_initiate_session` request +2. The brand agent MAY use the token to correlate offering lookups with sessions +3. The token MUST be treated as opaque by the host + +```json +{ + "offering_token": "offering_abc123xyz" +} +``` + +### Matching Products + +When `include_products` is true and `intent` is provided, the response MAY include matching products: + +```json +{ + "available": true, + "offering_token": "offering_abc123xyz", + "offering": { + "title": "Nike Summer Sale", + "summary": "Up to 50% off summer collection", + "price_hint": "from $89" + }, + "matching_products": [ + { + "product_id": "nike-air-max-90", + "name": "Nike Air Max 90", + "price": "$129", + "availability_summary": "Size 14 in stock" + } + ], + "total_matching": 12 +} +``` + +This enables hosts to show rich previews before session initiation. + +## Session Lifecycle + +### Session States + +**Schema**: [`enums/si-session-status.json`](https://adcontextprotocol.org/schemas/3.0.13/enums/si-session-status.json) + +SI sessions progress through the following states. Terminal states (`complete`, `terminated`) allow no further messages. + +| State | Description | +|-------|-------------| +| `active` | Session is in progress and accepting messages | +| `pending_handoff` | Brand agent is requesting handoff to a commerce or external flow | +| `complete` | Session ended normally after successful conversation or handoff | +| `terminated` | Session was ended due to timeout, error, policy violation, or explicit host/user action | + +### Session State Transitions + +``` +si_initiate_session ──▶ active + │ + ├── si_send_message ──▶ pending_handoff + │ │ + │ └── si_terminate_session ──▶ complete (terminal) + │ (handoff_transaction + │ or handoff_complete) + │ + ├── si_send_message ──▶ complete (terminal) + │ (conversation concluded) + │ + └── si_terminate_session ──▶ terminated (terminal) + (user_exit, session_timeout, + or host_terminated) + +Any non-terminal ── si_terminate_session(user_exit/timeout/host) ──▶ terminated +``` + +**Rules:** + +- Brand agents MUST return `session_status: "active"` from `si_initiate_session` on success +- Brand agents MUST return `session_status` on every `si_send_message` response +- When `session_status` is `pending_handoff`, the response MUST include a `handoff` object +- Brand agents MAY transition from `active` to `pending_handoff` on any `si_send_message` response when the conversation reaches a commerce or checkout intent +- Brand agents MAY transition from `active` directly to `complete` on a `si_send_message` response when the conversation has concluded (e.g., question answered, no further action needed) +- Hosts MUST call `si_terminate_session` to end a session. Brand agents MUST accept termination from any non-terminal state. +- Brand agents MUST return `SESSION_NOT_FOUND` for messages sent to an unknown or expired session +- Brand agents MUST return `SESSION_TERMINATED` for messages sent to a session in `complete` or `terminated` state. Brand agents that prioritize minimizing information disclosure MAY return `SESSION_NOT_FOUND` for terminated sessions as well — the recovery path is identical in both cases. +- Terminal states are irreversible — once a session is `complete` or `terminated`, a new session must be initiated + +### Session Timeout + +Sessions SHOULD have a maximum inactivity timeout. Brand agents MAY enforce timeout by transitioning idle sessions to `terminated`. + +- Brand agents SHOULD treat sessions as expired after a period of inactivity (RECOMMENDED: 5 minutes for conversational sessions) +- Brand agents SHOULD return `SESSION_NOT_FOUND` for messages sent to an expired session, rather than silently creating a new session +- Hosts SHOULD track `last_active_at` and warn users before session timeout when possible +- Brand agents MAY include `session_ttl_seconds` in the `si_initiate_session` response to communicate the timeout duration to the host + +### Initiate Session + +The `si_initiate_session` task establishes a new SI session. + +#### Request Requirements + +Hosts MUST include: + +- `intent` - Natural language description of user intent — the conversation handoff from host to brand agent +- `identity` - User identity with consent status + +Hosts SHOULD include: + +- `supported_capabilities` - Host's capability set for negotiation +- `offering_token` - Token from `si_get_offering` if performed + +Hosts MAY include: + +- `media_buy_id` - AdCP media buy ID if triggered by advertising +- `offering_id` - Brand-specific offering to apply +- `placement` - Where this session was triggered + +#### Response Requirements + +Brand agents MUST return: + +- `session_id` - Unique identifier for this session + +Brand agents SHOULD return: + +- `response.message` - Initial conversational message +- `negotiated_capabilities` - Intersection of brand and host capabilities + +### Send Message + +The `si_send_message` task exchanges messages within an active session. + +#### Request Requirements + +Hosts MUST include: + +- `session_id` - Active session identifier + +Hosts MUST include one of: + +- `message` - User's text message +- `action_response` - Response to a UI action + +#### Response Requirements + +Brand agents MUST return: + +- `session_id` - The session identifier +- `session_status` - Current session state (`active`, `pending_handoff`, or `complete`) + +Brand agents SHOULD return: + +- `response.message` - Conversational response + +If `session_status` is `pending_handoff`, the response MUST include: + +- `handoff` - Handoff configuration for commerce flow + +### Terminate Session + +The `si_terminate_session` task ends an SI session. + +#### Request Requirements + +Hosts MUST include: + +- `session_id` - Session to terminate +- `reason` - Termination reason + +#### Termination Reasons + +| Reason | Resulting State | Description | +|--------|----------------|-------------| +| `handoff_transaction` | `complete` | User proceeding to purchase; brand agent SHOULD return `acp_handoff` data | +| `handoff_complete` | `complete` | Conversation completed successfully | +| `user_exit` | `terminated` | User ended the session | +| `session_timeout` | `terminated` | Session timed out due to inactivity | +| `host_terminated` | `terminated` | Host ended the session (policy/error) | + +#### Handoff Data + +When `reason` is `handoff_transaction`, the brand agent SHOULD return an `acp_handoff` object in the termination response: + +| Field | Type | Description | +|-------|------|-------------| +| `checkout_url` | uri | Brand's ACP checkout endpoint. Hosts MUST validate this is HTTPS before opening (see Security Considerations). | +| `checkout_token` | string | Opaque token to pass to the checkout endpoint. Correlates the SI session with the transaction. | +| `payload` | object | Rich checkout context (product details, applied offers, pricing). Alternative to `checkout_token` for integrations that need structured data. | +| `expires_at` | datetime | When this handoff data expires. Hosts SHOULD initiate checkout before this time. | + +Brand agents SHOULD include either `checkout_token` or `payload` (or both) so the host can pass session context to the checkout endpoint. + +Brand agents MAY also return a `follow_up` object with post-session context (e.g., summary of what was discussed, next steps). + +## Capability Negotiation + +### Negotiation Process + +1. Brand declares capabilities in SI manifest +2. Host sends supported capabilities in session initiation +3. Brand returns negotiated (intersection) capabilities in response +4. Session uses only negotiated capabilities + +### Capability Categories + +#### Modalities + +Modalities define interaction modes: + +| Modality | Description | Required Support | +|----------|-------------|------------------| +| `conversational` | Text exchange | REQUIRED for all implementations | +| `voice` | Audio-based interaction | OPTIONAL | +| `video` | Video content playback | OPTIONAL | +| `avatar` | Animated video presence | OPTIONAL | + +All SI implementations MUST support `conversational` modality. + +#### Standard Components + +The following components MUST be renderable by all compliant hosts: + +| Component | Purpose | +|-----------|---------| +| `text` | Conversational message | +| `link` | URL with label | +| `image` | Single image | +| `product_card` | Product display with CTA | +| `carousel` | Array of cards/images | +| `action_button` | CTA that triggers callback | + +#### Extension Components + +Hosts MAY support additional components: + +| Component | Purpose | +|-----------|---------| +| `app_handoff` | Platform-specific app handoff | +| `integration_actions` | MCP/A2A installation prompts | + +Brand agents MUST NOT rely on extension components for core functionality. + +## UI Element Requirements + +### Standard Component Data + +Each standard component MUST include the required fields as defined in `si-ui-element.json`: + +**text**: `message` (required) + +**link**: `url`, `label` (required); `preview` (optional) + +**image**: `url`, `alt` (required); `caption` (optional) + +**product_card**: `title`, `price` (required); `subtitle`, `image_url`, `description`, `badge`, `cta` (optional) + +**carousel**: `items` (required); `title` (optional) + +**action_button**: `label`, `action` (required); `payload` (optional) + +### Action Handling + +When a user interacts with an `action_button`: + +1. The host MUST send an `action_response` via `si_send_message` +2. The `action_response` MUST include the `action` identifier +3. The `action_response` SHOULD include the `payload` if provided + +### Integration Actions + +The `integration_actions` component allows brand agents to offer persistent connections: + +```json +{ + "type": "integration_actions", + "data": { + "actions": [ + { "type": "mcp", "label": "Add as MCP Tool", "highlighted": true }, + { "type": "a2a", "label": "Connect via A2A" } + ] + } +} +``` + +Hosts MAY render integration actions if they support the integration type. + +## Identity and Privacy + +### Consent Requirements + +Hosts MUST obtain explicit user consent before sharing identity with brand agents. + +The consent flow MUST: + +1. Clearly identify what data will be shared +2. Reference the brand's privacy policy +3. Allow the user to decline + +### Identity Object + +When consent is granted, the `identity` object MUST include: + +- `consent_granted: true` +- `consent_timestamp` - When consent was obtained +- `consent_scope` - Array of data types consented to +- `privacy_policy_acknowledged.brand_policy_url` + +The `user` object MAY include: + +- `email` +- `name` +- `locale` +- `shipping_address` + +### Anonymous Sessions + +If consent is not granted: + +- `identity.consent_granted` MUST be `false` +- `identity.anonymous_session_id` SHOULD be provided +- No PII MUST be transmitted + +## Commerce Integration + +### ACP Handoff + +When `session_status` is `pending_handoff` with `handoff.type: "transaction"`: + +1. The host SHOULD initiate ACP checkout flow +2. The `handoff.intent` MUST describe the purchase intent +3. The `handoff.context_for_checkout` MAY include conversation context + +### Commerce Actions + +The `action_button` component MAY include commerce actions: + +| Action | Description | +|--------|-------------| +| `acp_checkout` | Initiate ACP checkout | +| `add_to_cart` | Add item to persistent cart | + +## Error Handling + +### Error Response + +Brand agents MUST return errors in the `errors` array using the standard error schema: + +```json +{ + "errors": [ + { + "code": "SESSION_NOT_FOUND", + "message": "Session has expired or does not exist" + } + ] +} +``` + +### Error Codes + +SI uses both standard AdCP error codes and SI-specific codes: + +**Standard AdCP error codes** (from [`enums/error-code.json`](https://adcontextprotocol.org/schemas/3.0.13/enums/error-code.json)): + +| Code | Description | +|------|-------------| +| `SESSION_NOT_FOUND` | Session ID is invalid, expired, or does not exist | +| `SESSION_TERMINATED` | Session has been terminated and cannot accept further messages | +| `REFERENCE_NOT_FOUND` | Generic fallback when the referenced SI offering, proposal, or other resource does not exist or is not accessible. Returned uniformly for both cases — see [Uniform response for inaccessible references](/dist/docs/3.0.13/building/by-layer/L3/error-handling#standard-error-codes) | +| `RATE_LIMITED` | Too many requests | +| `SERVICE_UNAVAILABLE` | Brand agent is temporarily unavailable | + +**SI-specific codes** (brand agents MAY return these for SI-specific errors): + +| Code | Description | +|------|-------------| +| `offer_unavailable` | Referenced offer is no longer available | +| `capability_unsupported` | Required capability not available | + +## Security Considerations + +### Transport Security + +All SI communications MUST use HTTPS with TLS 1.2 or higher. + +### Token Security + +- Availability tokens MUST be opaque and unpredictable +- Session IDs MUST be unique and unpredictable +- Tokens SHOULD expire within a reasonable timeframe + +### Handoff URL Validation + +Hosts MUST validate `checkout_url` in `acp_handoff` data before presenting it to users. Hosts SHOULD restrict to `https` scheme and MAY verify the domain matches the brand agent's registered domain. Hosts MUST NOT open `javascript:`, `data:`, or other non-HTTPS URIs from handoff data. + +### Data Minimization + +- Hosts MUST NOT send PII without consent +- Brand agents SHOULD minimize data collection +- Session data SHOULD be deleted after termination + +## Conformance + +### Host Conformance + +A conformant SI host MUST: + +1. Support MCP transport +2. Render all standard components +3. Implement session lifecycle (initiate, send, terminate) +4. Obtain consent before sharing identity +5. Support capability negotiation + +### Brand Agent Conformance + +A conformant SI brand agent MUST: + +1. Publish an SI manifest +2. Support at least one specified transport +3. Support conversational modality +4. Return valid session IDs +5. Handle all termination reasons + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0.0 | 2025-01 | Initial specification | diff --git a/dist/docs/3.0.13/sponsored-intelligence/tasks/index.mdx b/dist/docs/3.0.13/sponsored-intelligence/tasks/index.mdx new file mode 100644 index 0000000000..429ccdd89a --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/tasks/index.mdx @@ -0,0 +1,123 @@ +--- +title: Sponsored Intelligence Tasks +sidebarTitle: Task Reference +description: "AdCP Sponsored Intelligence task reference. Four tasks covering the full session lifecycle: si_get_offering, si_initiate_session, si_send_message, and si_terminate_session with MCP and A2A transport examples." +"og:title": "AdCP — Sponsored Intelligence Tasks" +--- + + +**Experimental.** Sponsored Intelligence (`si_get_offering`, `si_initiate_session`, `si_send_message`, `si_terminate_session`) 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 any of these tasks MUST declare `sponsored_intelligence.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +The Sponsored Intelligence Protocol defines four tasks for managing conversational brand experiences: + +## Session Lifecycle + +```mermaid +flowchart LR + P[si_get_offering] -.-> A[si_initiate_session] + A --> B[si_send_message] + B --> B + B --> C[si_terminate_session] + A --> C +``` + +### Two Entry Points + +**With pre-session lookup** (dotted line): Host calls `si_get_offering` first to show products before consent. The `offering_token` carries what was shown into the session. + +**Direct session** (solid line): Host calls `si_initiate_session` directly. Brand agent shows products as part of the conversation and tracks them internally. + +Both are valid. Use pre-session lookup for sponsored search results where you want to preview products before asking the user to engage. + +## Tasks + +| Task | Description | Initiator | +|------|-------------|-----------| +| [`si_get_offering`](./si_get_offering) | Get offering details, availability, and matching products (anonymous) | Host | +| [`si_initiate_session`](./si_initiate_session) | Start a conversation with a brand agent | Host | +| [`si_send_message`](./si_send_message) | Exchange messages within an active session | Host | +| [`si_terminate_session`](./si_terminate_session) | End the session with appropriate handoff | Either | + +## Transport Options + +SI tasks work over both MCP and A2A protocols: + +### MCP Transport + +```json +{ + "method": "tools/call", + "params": { + "name": "si_initiate_session", + "arguments": { + "intent": "User wants to fly to Boston next Tuesday morning", + "identity": { /* ... */ } + } + } +} +``` + +### A2A Transport + +```json +{ + "task": "si_initiate_session", + "payload": { + "intent": "User wants to fly to Boston next Tuesday morning", + "identity": { /* ... */ } + } +} +``` + +## Common Patterns + +### Minimal Session (No Identity) + +For anonymous browsing without personalization: + +```json +{ + "intent": "User interested in product information", + "identity": { + "consent_granted": false, + "anonymous_session_id": "anon_xyz789" + } +} +``` + +### Full Identity Session + +For personalized experiences with consented PII: + +```json +{ + "intent": "User wants to book a flight", + "identity": { + "consent_granted": true, + "consent_timestamp": "2026-01-18T10:30:00Z", + "consent_scope": ["name", "email", "shipping_address"], + "user": { + "email": "jane@example.com", + "name": "Jane Smith" + } + } +} +``` + +### Campaign-Triggered Session + +When SI is invoked as part of a media buy: + +```json +{ + "intent": "User searching for flights to Boston", + "media_buy_id": "media_buy_q1_promo", + "placement": "chatgpt_search", + "offering_id": "premium_upgrade_offer", + "identity": { + "consent_granted": true, + "user": { "email": "jane@example.com" } + } +} +``` diff --git a/dist/docs/3.0.13/sponsored-intelligence/tasks/si_get_offering.mdx b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_get_offering.mdx new file mode 100644 index 0000000000..c733287ded --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_get_offering.mdx @@ -0,0 +1,313 @@ +--- +title: si_get_offering +description: "si_get_offering is the AdCP task for anonymous pre-session offering lookups. Retrieve offering details, matching products, and an offering token for session continuity — without requiring user PII." +"og:title": "AdCP — si_get_offering" +--- + + +**Experimental.** Sponsored Intelligence (`si_get_offering`, `si_initiate_session`, `si_send_message`, `si_terminate_session`) 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 any of these tasks MUST declare `sponsored_intelligence.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Get offering details, availability, and optionally matching products before initiating a session. This allows hosts to show rich previews to users before asking for consent to engage with the brand. + +## When to Use This Task + +There are two valid flows for starting an SI session: + +### Flow A: Pre-Session Lookup (Recommended for Sponsored Results) + +``` +si_get_offering → Host shows products → User consents → si_initiate_session with offering_token +``` + +Use this when you want to show the user products **before** asking for consent. The `offering_token` bridges what was shown into the session, so references like "the second one" work. + +**Example**: Search results page shows "Nike has 3 running shoes in your size from \$89" before the user decides to engage. + +### Flow B: Direct Session + +``` +si_initiate_session → Brand shows products in first response → Conversation continues +``` + +Use this when the user has already expressed intent to engage. The brand agent shows products as part of the session, tracking what they showed internally. + +**Example**: User says "I want to talk to Nike about running shoes" - no need to pre-fetch, just start the session. + +The key difference: `si_get_offering` is for **anonymous pre-consent previews**. If you're going straight into a session, skip it. + +## Purpose + +The offering lookup serves three purposes: + +1. **Show offering details** - Display pricing, availability, and descriptions to users before consent +2. **Surface matching products** - When given an `intent`, return relevant products from the offering +3. **Session continuity** - The returned token preserves what was shown, so the brand agent knows what the user already saw when the session starts + +## Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `offering_id` | string | Yes | Offering identifier from the catalog | +| `intent` | string | No | Natural language description of user intent for personalized results (no PII) | +| `include_products` | boolean | No | Whether to include matching products (default: false) | +| `product_limit` | integer | No | Max products to return (default: 5, max: 50) | +| `context` | object | No | Opaque correlation data echoed unchanged in the response | + +### Privacy + +This request must not include any personally identifiable information. The `intent` field describes what the user is looking for but must be anonymous (e.g., "mens size 14 near Cincinnati" is OK, but email addresses are not). + +## Response + +| Field | Type | Description | +|-------|------|-------------| +| `available` | boolean | Whether the offering is currently available | +| `offering_token` | string | Token to pass to `si_initiate_session` | +| `ttl_seconds` | integer | How long this information is valid | +| `checked_at` | string | ISO 8601 timestamp of the lookup | +| `offering` | object | Offering details | +| `matching_products` | array | Products matching the intent (if requested) | +| `total_matching` | integer | Total products matching (may exceed returned count) | +| `unavailable_reason` | string | Why offering is unavailable (if not available) | +| `alternative_offering_ids` | array | Alternative offerings to check | + +### Offering Object + +| Field | Type | Description | +|-------|------|-------------| +| `offering_id` | string | Offering identifier | +| `title` | string | Offering title | +| `summary` | string | Brief description | +| `tagline` | string | Short promotional tagline | +| `expires_at` | string | When the offering expires | +| `price_hint` | string | Price indication (e.g., "from \$89") | +| `image_url` | string | Hero image URL | +| `landing_url` | string | Landing page URL | + +### Matching Product Object + +| Field | Type | Description | +|-------|------|-------------| +| `product_id` | string | Product identifier | +| `name` | string | Product name | +| `price` | string | Display price | +| `original_price` | string | Original price if on sale | +| `image_url` | string | Product image URL | +| `availability_summary` | string | Brief availability info | +| `url` | string | Product detail page URL | + +### Unavailable Reasons + +| Reason | Description | +|--------|-------------| +| `sold_out` | Product/offering inventory exhausted | +| `expired` | Offering past its end date | +| `region_restricted` | Not available in user's region | +| `inactive` | Campaign paused or ended | + +## Examples + +### Basic Offering Lookup + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-get-offering-request.json", + "offering_id": "nike-summer-sale" +} +``` + +### Response + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-get-offering-response.json", + "available": true, + "offering_token": "offering_abc123xyz", + "ttl_seconds": 3600, + "checked_at": "2025-01-19T10:00:00Z", + "offering": { + "offering_id": "nike-summer-sale", + "title": "Nike Summer Sale", + "summary": "Up to 50% off summer collection", + "price_hint": "from $89", + "expires_at": "2025-08-31T23:59:59Z" + } +} +``` + +### With Product Context + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-get-offering-request.json", + "offering_id": "nike-summer-sale", + "intent": "mens size 14 running shoes near Cincinnati", + "include_products": true, + "product_limit": 3 +} +``` + +### Response with Products + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-get-offering-response.json", + "available": true, + "offering_token": "offering_abc123xyz", + "ttl_seconds": 3600, + "checked_at": "2025-01-19T10:00:00Z", + "offering": { + "offering_id": "nike-summer-sale", + "title": "Nike Summer Sale", + "summary": "Up to 50% off summer collection", + "price_hint": "from $89" + }, + "matching_products": [ + { + "product_id": "nike-pegasus-41", + "name": "Nike Pegasus 41", + "price": "$89", + "original_price": "$130", + "image_url": "https://cdn.nike.com/pegasus-41.jpg", + "availability_summary": "Size 14 in stock" + }, + { + "product_id": "nike-air-max-90", + "name": "Nike Air Max 90", + "price": "$129", + "image_url": "https://cdn.nike.com/air-max-90.jpg", + "availability_summary": "Size 14 in stock" + } + ], + "total_matching": 12 +} +``` + +This enables the host to show: + +> "Nike has 12 running shoes in your size starting at \$89. Want to explore with their assistant?" + +### Unavailable Response + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-get-offering-response.json", + "available": false, + "checked_at": "2025-01-19T10:00:00Z", + "unavailable_reason": "expired", + "alternative_offering_ids": [ + "nike-fall-collection", + "nike-clearance" + ] +} +``` + +## Using the Offering Token + +The `offering_token` is the key to **session continuity**. When a user sees products from `si_get_offering` and then initiates a conversation, the token allows the brand agent to know exactly what was shown. + +### Why Session Continuity Matters + +Without the token, this conversation breaks: + +``` +Host: "Nike has 3 running shoes in size 14: Pegasus 41 ($89), Air Max 90 ($129), Vomero 18 ($139)" +User: "Tell me more about the second one" +Host → si_initiate_session: { intent: "User wants more info about the second shoe" } +Brand Agent: ??? (Which shoes were shown? In what order?) +``` + +With the token, the brand agent can reconstruct the full context: + +``` +Host → si_initiate_session: { + intent: "User wants more info about the second shoe", + offering_token: "offering_abc123xyz" +} +Brand Agent: (Looks up token → sees Pegasus, Air Max, Vomero were shown in that order) +Brand Agent: "The Air Max 90 is a classic! It's part of our summer sale..." +``` + +### How Brand Agents Should Use Tokens + +When generating an `offering_token`, store the full query state server-side: + +```typescript +// When returning si_get_offering response +const token = generateToken(); +await store.save(token, { + offering_id: request.offering_id, + intent: request.intent, + products_shown: matchingProducts, // In exact order returned + product_ids: matchingProducts.map(p => p.product_id), + queried_at: new Date().toISOString(), + ttl: 3600 +}); + +return { + available: true, + offering_token: token, + matching_products: matchingProducts, + // ... +}; +``` + +When receiving the token in `si_initiate_session`: + +```typescript +// Retrieve the pre-session context +const preContext = await store.get(request.offering_token); +if (preContext) { + // Now you know exactly what was shown + // "the second one" = preContext.products_shown[1] +} +``` + +### Including the Token in Session Initiation + +When initiating a session after getting offering details, include the token: + +```json +{ + "intent": "User wants running shoes, mens size 14", + "offering_id": "nike-summer-sale", + "offering_token": "offering_abc123xyz", + "identity": { + "consent_granted": true, + "user": { ... } + } +} +``` + +## Key Points + +1. **Anonymous by design** - No user data is sent with offering lookups. This protects user privacy while enabling hosts to show rich previews. + +2. **Session continuity** - The offering token is the brand's memory of what was shown. When users reference "the first option" or "that blue one", the token lets the brand agent resolve those references. + +3. **Product matching** - When `include_products` is true and `intent` is provided, brands can return relevant products. This powers pre-session previews like "12 shoes in your size from \$89." + +4. **Caching** - Hosts may cache responses for up to `ttl_seconds`. This reduces load on brand agents for frequently checked offerings. + +5. **Graceful degradation** - If the lookup fails or times out, hosts may still initiate sessions directly. The offering lookup is optional. + +6. **Alternative suggestions** - When offerings are unavailable, brand agents may suggest alternatives via `alternative_offering_ids`. + +## Best Practices + +### For Hosts + +- Get offering details before showing sponsored results to users +- Use `include_products` with an `intent` for richer previews +- Respect TTL for caching to avoid stale data +- Handle unavailable gracefully - don't show expired offerings +- Include offering token in session initiation when available + +### For Brand Agents + +- Return rich `offering` details to help hosts display accurate information +- Support `include_products` for contextual product matching +- Use reasonable TTL values (e.g., 5-60 minutes depending on volatility) +- Provide helpful `unavailable_reason` for debugging +- Suggest alternatives when primary offering is unavailable diff --git a/dist/docs/3.0.13/sponsored-intelligence/tasks/si_initiate_session.mdx b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_initiate_session.mdx new file mode 100644 index 0000000000..a46424ec44 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_initiate_session.mdx @@ -0,0 +1,189 @@ +--- +title: si_initiate_session +description: "si_initiate_session is the AdCP task that starts a Sponsored Intelligence session. Pass user intent, consented identity, and host capabilities to a brand agent and receive a personalized response." +"og:title": "AdCP — si_initiate_session" +--- + + +**Experimental.** Sponsored Intelligence (`si_get_offering`, `si_initiate_session`, `si_send_message`, `si_terminate_session`) 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 any of these tasks MUST declare `sponsored_intelligence.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Start a conversational session with a brand agent. The host platform invokes this task when a user expresses interest in engaging with a brand. + +## Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `intent` | string | Yes | Natural language description of user intent — the conversation handoff from the host | +| `identity` | object | Yes | User identity with consent status | +| `media_buy_id` | string | No | AdCP media buy ID if triggered by advertising | +| `placement` | string | No | Where the session was triggered (e.g., "chatgpt_search") | +| `offering_id` | string | No | Brand-specific offering reference to apply | +| `supported_capabilities` | object | No | What the host platform supports | +| `offering_token` | string | No | Token from `si_get_offering` for correlation | +| `context` | object | No | Opaque correlation data echoed unchanged in the response (see [application context](/dist/docs/3.0.13/building/by-layer/L2/context-sessions#application-context-context)) | + +### Offering Token + +If a host performed a [`si_get_offering`](./si_get_offering) lookup before initiating, include the token for **session continuity**: + +```json +{ + "offering_token": "offering_abc123xyz" +} +``` + +The token lets the brand agent know exactly what products were shown to the user (and in what order). This enables natural conversation flow: + +- User sees: "Nike Pegasus (\$89), Air Max (\$129), Vomero (\$139)" +- User says: "Tell me more about the middle one" +- Brand agent resolves "middle one" → Air Max via the token's stored context + +### Identity Object + +When `consent_granted` is `true`: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `consent_granted` | boolean | Yes | Must be `true` | +| `consent_timestamp` | string | Yes | ISO 8601 timestamp of consent | +| `consent_scope` | array | Yes | Fields the user agreed to share | +| `privacy_policy_acknowledged` | object | No | Brand policy user accepted | +| `user` | object | Yes | User's PII | + +When `consent_granted` is `false`: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `consent_granted` | boolean | Yes | Must be `false` | +| `anonymous_session_id` | string | Yes | Unique ID for this anonymous session | + +### Supported Capabilities Object + +Declares what the host platform can render: + +```json +{ + "modalities": { + "conversational": true, + "voice": { "providers": ["elevenlabs", "openai"] }, + "video": false, + "avatar": false + }, + "components": { + "standard": ["text", "link", "image", "product_card", "carousel", "action_button"], + "extensions": { + "chatgpt_apps_sdk": "1.0" + } + }, + "commerce": { + "acp_checkout": true + } +} +``` + +## Response + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Unique identifier for this session | +| `response` | object | Brand agent's initial response | +| `negotiated_capabilities` | object | Intersection of brand and host capabilities | + +### Response Object + +| Field | Type | Description | +|-------|------|-------------| +| `message` | string | Text response from brand agent | +| `ui_elements` | array | Visual components to render | + +## Example + +### Request + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-initiate-session-request.json", + "idempotency_key": "f6a7b8c9-d0e1-4234-f567-234567890123", + "intent": "User wants to fly to Boston next Tuesday morning on flight 632 at 6 AM.", + "media_buy_id": "delta_q1_premium_upgrade", + "placement": "chatgpt_search", + "offering_id": "delta_chatgpt_3313", + "identity": { + "consent_granted": true, + "consent_timestamp": "2026-01-18T10:30:00Z", + "consent_scope": ["name", "email"], + "privacy_policy_acknowledged": { + "brand_policy_url": "https://delta.com/privacy", + "brand_policy_version": "2026-01" + }, + "user": { + "email": "jane@example.com", + "name": "Jane Smith", + "locale": "en-US" + } + }, + "supported_capabilities": { + "modalities": { + "conversational": true, + "voice": true + }, + "components": { + "standard": ["text", "link", "image", "product_card", "carousel", "action_button"] + }, + "commerce": { + "acp_checkout": true + } + } +} +``` + +### Response + +```json +{ + "$schema": "/schemas/3.0.13/sponsored-intelligence/si-initiate-session-response.json", + "session_id": "sess_abc123", + "session_status": "active", + "response": { + "message": "Hi Jane! I found DL632 departing at 6:15 AM next Tuesday. Great news—as a SkyMiles Gold member, you qualify for our free Premium Economy upgrade on this flight.", + "ui_elements": [ + { + "type": "product_card", + "data": { + "title": "DL632 to Boston - Tue Jan 27", + "subtitle": "6:15 AM → 9:42 AM (3h 27m)", + "price": "$199", + "badge": "Free Premium Economy Upgrade", + "image_url": "https://delta.com/images/premium-economy.jpg", + "cta": { "label": "Book with Upgrade", "action": "checkout" } + } + } + ] + }, + "negotiated_capabilities": { + "modalities": { + "conversational": true, + "voice": true + }, + "components": { + "standard": ["text", "link", "image", "product_card", "carousel", "action_button"] + }, + "commerce": { + "acp_checkout": true + } + } +} +``` + +## Key Points + +1. **`intent` is the conversation handoff** - The host tells the brand agent what the user needs in natural language. The brand agent responds naturally, continuing the conversation. `context` is a separate, optional field: an opaque object (e.g., `{"trace_id": "abc-123"}`) that the brand agent echoes back unchanged — used by the buyer for correlation, never parsed by the brand agent. + +2. **Brand looks up loyalty data** - If Jane's email is recognized, Delta retrieves her SkyMiles status automatically. Hosts don't store loyalty numbers. + +3. **offering_id is brand-specific** - The brand interprets this reference to apply promotions, discounts, or loyalty rewards. Hosts pass it through without needing to understand offering semantics. + +4. **Capability negotiation** - The response includes `negotiated_capabilities` showing what features this session can use (intersection of brand and host capabilities). + +5. **Clear PII with explicit consent** - When `consent_granted` is true, actual email/name are passed (not hashed). This is a direct, consented handoff. diff --git a/dist/docs/3.0.13/sponsored-intelligence/tasks/si_send_message.mdx b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_send_message.mdx new file mode 100644 index 0000000000..04a3a7cac1 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_send_message.mdx @@ -0,0 +1,214 @@ +--- +title: si_send_message +description: "si_send_message is the AdCP task for exchanging messages in an active SI session. Relay user text or action responses to the brand agent and handle active, pending_handoff, or complete session states." +"og:title": "AdCP — si_send_message" +--- + + +**Experimental.** Sponsored Intelligence (`si_get_offering`, `si_initiate_session`, `si_send_message`, `si_terminate_session`) 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 any of these tasks MUST declare `sponsored_intelligence.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Send a message within an active SI session. The host invokes this task to relay user messages and action responses to the brand agent. + +## Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session_id` | string | Yes | Session ID from `si_initiate_session` | +| `message` | string | No | User's text message | +| `action_response` | object | No | Response to a UI action (button click, form submit) | + +At least one of `message` or `action_response` must be provided. + +### Action Response Object + +When the user interacts with a UI element: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | Yes | The action identifier from the UI element | +| `element_id` | string | No | ID of the specific UI element | +| `payload` | object | No | Additional data from the interaction | + +## Response + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Confirms the active session | +| `response` | object | Brand agent's response | +| `session_status` | string | Current session state | +| `handoff` | object | Present when session_status is "pending_handoff" | + +### Session Status Values + +| Status | Description | +|--------|-------------| +| `active` | Session continues normally | +| `pending_handoff` | Brand agent signals readiness to hand off | +| `complete` | Conversation is done | + +### Handoff Object + +When `session_status` is `pending_handoff`: + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | "transaction" or "complete" | +| `intent` | object | For transactions: what the user wants to buy | +| `context_for_checkout` | object | Summary for ACP handoff | + +## Examples + +### Simple Message Exchange + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "message": "Do you have any earlier flights?" +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "response": { + "message": "Yes! There's DL628 departing at 5:30 AM. It's a bit earlier but also qualifies for the Premium Economy upgrade.", + "ui_elements": [ + { + "type": "carousel", + "data": { + "items": [ + { + "title": "DL628 - 5:30 AM", + "subtitle": "Arrives 8:57 AM", + "price": "$199", + "badge": "Free Upgrade" + }, + { + "title": "DL632 - 6:15 AM", + "subtitle": "Arrives 9:42 AM", + "price": "$199", + "badge": "Free Upgrade" + } + ] + } + } + ] + }, + "session_status": "active" +} +``` + +### Action Response (Button Click) + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "action_response": { + "action": "select_flight", + "payload": { + "flight_number": "DL628", + "departure_time": "05:30" + } + } +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "response": { + "message": "Great choice! DL628 is confirmed with your Premium Economy upgrade. Ready to book?", + "ui_elements": [ + { + "type": "product_card", + "data": { + "title": "DL628 to Boston", + "subtitle": "Tue Jan 27, 5:30 AM → 8:57 AM", + "price": "$199", + "badge": "Premium Economy", + "cta": { "label": "Book Now", "action": "checkout" } + } + } + ] + }, + "session_status": "active" +} +``` + +### Transaction Handoff + +When the user is ready to purchase: + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "action_response": { + "action": "checkout" + } +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "response": { + "message": "Perfect! I'll hand you back to complete the booking." + }, + "session_status": "pending_handoff", + "handoff": { + "type": "transaction", + "intent": { + "action": "purchase", + "product": { + "type": "flight", + "flight_number": "DL628", + "departure": "2026-01-27T05:30:00-05:00", + "arrival": "2026-01-27T08:57:00-05:00", + "origin": "JFK", + "destination": "BOS", + "class": "premium_economy" + }, + "price": { + "amount": 199, + "currency": "USD" + } + }, + "context_for_checkout": { + "conversation_summary": "Jane selected DL628 JFK→BOS on Jan 27 with free Premium Economy upgrade via campaign offer", + "applied_offers": ["delta_chatgpt_3313"] + } + } +} +``` + +## Handling Handoffs + +When you receive `session_status: "pending_handoff"`: + +1. **For `type: "transaction"`** - Initiate ACP checkout with the provided intent and context +2. **For `type: "complete"`** - The conversation is done; return to normal chat + +The host should call `si_terminate_session` after handling the handoff to properly close the session. + +## Key Points + +1. **Message or action_response** - Each request needs at least one. Users can type messages or interact with UI elements. + +2. **Session status drives flow** - Check `session_status` on every response to know if the conversation continues or needs handoff. + +3. **Handoff preserves context** - The `context_for_checkout` object gives ACP everything needed for a seamless purchase experience. + +4. **UI elements are optional** - Brand agent decides when to include cards, carousels, etc. based on the conversation. diff --git a/dist/docs/3.0.13/sponsored-intelligence/tasks/si_terminate_session.mdx b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_terminate_session.mdx new file mode 100644 index 0000000000..bdf353e572 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/tasks/si_terminate_session.mdx @@ -0,0 +1,288 @@ +--- +title: si_terminate_session +description: "si_terminate_session is the AdCP task for ending an SI session. Supports five termination reasons including transaction handoff to ACP, with follow-up suggestions and checkout context for commerce." +"og:title": "AdCP — si_terminate_session" +--- + + +**Experimental.** Sponsored Intelligence (`si_get_offering`, `si_initiate_session`, `si_send_message`, `si_terminate_session`) 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 any of these tasks MUST declare `sponsored_intelligence.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +End an SI session. Either the host or brand agent can initiate termination, with different reasons indicating how the session concluded. + +## Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `session_id` | string | Yes | Session ID to terminate | +| `reason` | string | Yes | Why the session is ending | +| `termination_context` | object | No | Additional context for the termination | + +### Termination Reasons + +| Reason | Meaning | Typical Initiator | +|--------|---------|-------------------| +| `handoff_transaction` | User wants to complete a purchase | Brand agent (via pending_handoff) | +| `handoff_complete` | Conversation naturally concluded | Brand agent | +| `user_exit` | User explicitly ended the conversation | Host | +| `session_timeout` | Inactivity timeout reached | Host | +| `host_terminated` | Host ended for policy/error reasons | Host | + +### Termination Context Object + +Additional details vary by reason: + +**For `handoff_transaction`:** +```json +{ + "intent": { /* purchase intent from handoff */ }, + "context_for_checkout": { /* ACP context */ } +} +``` + +**For `user_exit`:** +```json +{ + "user_signal": "changed_topic", + "partial_context": { /* what was discussed */ } +} +``` + +**For `session_timeout`:** +```json +{ + "last_activity": "2026-01-18T10:30:00Z", + "timeout_seconds": 300 +} +``` + +## Response + +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | string | Confirms which session was terminated | +| `terminated` | boolean | Always `true` on success | +| `acp_handoff` | object | Present for transaction handoffs | +| `follow_up` | object | Optional actions for future engagement | + +### ACP Handoff Object + +For transaction terminations, includes data needed for ACP checkout: + +| Field | Type | Description | +|-------|------|-------------| +| `checkout_url` | uri | Brand's ACP checkout endpoint. Hosts MUST validate this is HTTPS. | +| `checkout_token` | string | Opaque token to pass to the checkout endpoint | +| `payload` | object | Rich checkout context (product details, applied offers, pricing). Alternative to `checkout_token` for structured data. | +| `expires_at` | datetime | When this handoff data expires | + +### Follow-Up Object + +Suggestions for future engagement: + +| Field | Type | Description | +|-------|------|-------------| +| `suggested_action` | string | What the host might do next | +| `data` | object | Relevant data for the action | +| `message` | string | Optional message to display | + +## Examples + +### Transaction Handoff + +After receiving `pending_handoff` with `type: "transaction"`: + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "reason": "handoff_transaction", + "termination_context": { + "intent": { + "action": "purchase", + "product": { + "type": "flight", + "flight_number": "DL628" + } + } + } +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "terminated": true, + "acp_handoff": { + "checkout_url": "https://delta.com/acp/checkout", + "payload": { + "session_id": "sess_abc123", + "flight": "DL628", + "passenger": { + "email": "jane@example.com", + "name": "Jane Smith" + }, + "applied_offers": ["delta_chatgpt_3313"], + "price": { + "amount": 199, + "currency": "USD" + } + }, + "expires_at": "2026-01-18T11:00:00Z" + } +} +``` + +### Conversation Complete (No Purchase) + +When the conversation naturally ends without a transaction: + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "reason": "handoff_complete" +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "terminated": true, + "follow_up": { + "suggested_action": "save_for_later", + "data": { + "flights_discussed": ["DL628", "DL632"], + "destination": "BOS", + "travel_date": "2026-01-27" + }, + "message": "Let me know if you'd like to revisit Boston flights later!" + } +} +``` + +### User Exit + +When the user changes topic or explicitly leaves: + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "reason": "user_exit", + "termination_context": { + "user_signal": "changed_topic", + "partial_context": { + "flights_viewed": ["DL628"], + "last_topic": "seat selection" + } + } +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "terminated": true, + "follow_up": { + "suggested_action": "remind_later", + "data": { + "incomplete_booking": { + "flight": "DL628", + "step": "seat_selection" + } + } + } +} +``` + +### Session Timeout + +When the session times out due to inactivity: + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "reason": "session_timeout", + "termination_context": { + "last_activity": "2026-01-18T10:25:00Z", + "timeout_seconds": 300 + } +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "terminated": true +} +``` + +### Host Terminated + +When the host ends the session for policy or error reasons: + +**Request:** + +```json +{ + "session_id": "sess_abc123", + "reason": "host_terminated", + "termination_context": { + "cause": "user_left_app" + } +} +``` + +**Response:** + +```json +{ + "session_id": "sess_abc123", + "terminated": true +} +``` + +## ACP Integration Flow + +When the reason is `handoff_transaction`: + +```mermaid +sequenceDiagram + participant H as Host + participant B as Brand Agent + participant A as ACP + + H->>B: si_terminate_session(handoff_transaction) + B->>H: { acp_handoff: { checkout_url, checkout_token/payload } } + H->>A: Initiate checkout with token/payload + A->>H: Checkout flow +``` + +1. Host receives `acp_handoff` in the termination response +2. Host initiates ACP checkout using the provided `checkout_url` and `checkout_token` or `payload` +3. ACP handles the transaction while maintaining the user's trust with the host +4. Brand is not the merchant of record - ACP handles payment + +## Key Points + +1. **Always terminate sessions** - Even if the conversation seems done, call terminate to clean up resources and get follow-up suggestions. + +2. **ACP handoff data has expiration** - The `expires_at` field indicates how long the checkout context is valid. + +3. **Follow-up enables re-engagement** - Even non-transaction terminations can include suggestions for future engagement. + +4. **Host maintains trust** - Transactions go through ACP, keeping the user's relationship with the host intact. diff --git a/dist/docs/3.0.13/sponsored-intelligence/workflow.mdx b/dist/docs/3.0.13/sponsored-intelligence/workflow.mdx new file mode 100644 index 0000000000..0badfc9e36 --- /dev/null +++ b/dist/docs/3.0.13/sponsored-intelligence/workflow.mdx @@ -0,0 +1,211 @@ +--- +title: End-to-end workflow +description: "The complete Sponsored Intelligence workflow — from account setup through catalog sync, product discovery, media buy creation, and delivery reporting." +"og:title": "AdCP — SI workflow" +sidebarTitle: Workflow +--- + +# End-to-end workflow + + + +## Set up account + +Start by checking the seller's capabilities to understand the account model. First-party AI platforms typically require explicit accounts (each advertiser authenticates via OAuth), while [ad networks](/dist/docs/3.0.13/sponsored-intelligence/networks) may use implicit accounts (the agent declares brands via `sync_accounts`). + +**Example: first-party AI platform (explicit accounts)** + +```json +{ + "adcp": { "major_versions": [3] }, + "supported_protocols": ["media_buy", "creative"], + "account": { + "require_operator_auth": true, + "supported_billing": ["operator"], + "authorization_endpoint": "https://ads.ai-platform.example.com/oauth/authorize", + "required_for_products": false, + "sandbox": true + } +} +``` + +Key signals: +- **`require_operator_auth: true`** — each advertiser authenticates via OAuth +- **`sandbox: true`** — test accounts available for integration validation +- **`required_for_products: false`** — buyers can browse products before setting up an account + +An ad network that aggregates across multiple platforms would more likely declare `require_operator_auth: false` with `supported_billing: ["operator", "agent"]` — the agent is trusted and declares accounts via `sync_accounts`. + +For explicit accounts, after OAuth authentication discover available accounts via `list_accounts`: + +```json +{ + "accounts": [ + { + "account_id": "acct_novabrand_ai_001", + "name": "Nova Brand - AI Platform", + "status": "active", + "sandbox": false + }, + { + "account_id": "acct_novabrand_ai_sandbox", + "name": "Nova Brand - Sandbox", + "status": "active", + "sandbox": true + } + ] +} +``` + +Use the sandbox account first to validate the full integration before committing real spend. + +## Sync catalogs + +This is the defining step — pushing your product and offering data into the platform so it has the raw material to generate ads and transact. Product catalogs feed creative generation. Offering catalogs enable promotions and commerce handoffs. The richer the feed, the better the platform can match intent to inventory. + +```json +{ + "account": { "account_id": "acct_novabrand_ai_001" }, + "catalogs": [ + { + "catalog_id": "product-feed", + "name": "Nova Brand Product Catalog", + "type": "product", + "url": "https://novabrand.example.com/products.xml", + "feed_format": "google_merchant_center", + "update_frequency": "daily" + }, + { + "catalog_id": "offerings-feed", + "name": "Nova Brand Promotions", + "type": "offering", + "url": "https://novabrand.example.com/offerings.json", + "feed_format": "custom", + "update_frequency": "weekly" + } + ] +} +``` + +The platform ingests each feed: +- **Product catalog** — titles, descriptions, prices, and images feed sponsored response generation +- **Offering catalog** — promotions, services, and seasonal campaigns for [SI Chat Protocol](/dist/docs/3.0.13/sponsored-intelligence/si-chat-protocol) brand experience handoffs + + +Improving your ads means improving what you push in — catalogs, conversion events, brand identity, and [content standards](/dist/docs/3.0.13/governance/content-standards/index). Include detailed descriptions, multiple images, and structured attributes in catalogs. Push conversion events so the platform knows what works. The platform generates ads from all of this data — richer input produces better output. + + +## Discover products + +Query `get_products` with `channels: ["sponsored_intelligence"]` to find Sponsored Intelligence products: + +```json +{ + "buying_mode": "brief", + "brief": "Promote our new wireless headphones to tech-savvy consumers on AI platforms.", + "brand": { + "domain": "novabrand.example.com" + }, + "filters": { + "channels": ["sponsored_intelligence"] + } +} +``` + +The seller returns products matching the brief. For catalog-driven products, sellers may include `catalog_match` showing which catalog items are eligible. See the [product spectrum](/dist/docs/3.0.13/sponsored-intelligence/product-spectrum) for the full range of product types. + +## Create media buy + +A media buy can span multiple Sponsored Intelligence product types: + +```json +{ + "account": { "account_id": "acct_novabrand_ai_001" }, + "brand": { + "domain": "novabrand.example.com" + }, + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-04-30T23:59:59Z", + "packages": [ + { + "product_id": "sponsored_response_assistant", + "pricing_option_id": "sr_cpc", + "budget": 10000, + "bid_price": 2.50, + "pacing": "even", + "optimization_goals": [{ + "kind": "metric", + "metric": "engagements", + "target": { "kind": "cost_per", "value": 3.00 }, + "priority": 1 + }] + }, + { + "product_id": "ai_search_sponsored", + "pricing_option_id": "search_cpc", + "budget": 5000, + "bid_price": 3.00, + "pacing": "even", + "targeting_overlay": { + "keyword_targets": [ + { "keyword": "wireless headphones", "match_type": "broad" }, + { "keyword": "noise cancelling", "match_type": "phrase" }, + { "keyword": "bluetooth earbuds", "match_type": "broad" } + ] + } + } + ] +} +``` + +The sponsored responses package uses `optimization_goals` with a metric goal to optimize for cost-per-engagement. The AI search package uses `keyword_targets` to reach relevant queries. + +## Delivery reporting + +Delivery reports include engagement metrics alongside standard delivery data: + +```json +{ + "reporting_period": { + "start": "2026-04-01T00:00:00Z", + "end": "2026-04-14T23:59:59Z" + }, + "currency": "USD", + "media_buy_deliveries": [ + { + "media_buy_id": "mb_ai_001", + "status": "active", + "totals": { + "impressions": 125000, + "spend": 7200 + }, + "by_package": [ + { + "package_id": "pkg_sr_001", + "pricing_model": "cpc", + "rate": 2.40, + "currency": "USD", + "impressions": 85000, + "spend": 4800, + "clicks": 2000, + "delivery_status": "delivering" + }, + { + "package_id": "pkg_search_001", + "pricing_model": "cpc", + "rate": 3.00, + "currency": "USD", + "impressions": 40000, + "spend": 2400, + "clicks": 800, + "delivery_status": "delivering" + } + ] + } + ] +} +``` + +See [measurement](/dist/docs/3.0.13/sponsored-intelligence/measurement) for metric definitions and conversion tracking. + + diff --git a/dist/docs/3.0.13/trust.mdx b/dist/docs/3.0.13/trust.mdx new file mode 100644 index 0000000000..407e437b4c --- /dev/null +++ b/dist/docs/3.0.13/trust.mdx @@ -0,0 +1,125 @@ +--- +title: Trust & Security +sidebarTitle: Trust & Security +description: "How AdCP structures decisions for verifiability and oversight — the seams the protocol provides, and what deployers are responsible for." +"og:title": "AdCP — Trust & Security" +--- + + +AdCP is a building block, not a compliance shortcut. The protocol exposes structured fields that let a deployer discharge their obligations — it does not itself perform conformity assessment, enforce policy, or guarantee outcomes. See [Known Limitations](/dist/docs/3.0.13/reference/known-limitations) for an explicit list of what AdCP does not do. + + +AdCP's trust posture rests on a single structural principle: **no single agent can act unilaterally, and every decision is cryptographically re-verifiable by any party that cares to check.** A governance agent validates plans before money moves. A JWS-signed `governance_context` travels with every buy so the seller can independently confirm authorization without trusting the buyer's word. The compliance runner can re-execute storyboard output from a `runner-output.json` months later, producing the same pass/fail rows a regulator would see. + +What AdCP does not do is enforce deployer policy. The seams are hooks — the deployer wires them to their own governance platform, policy registry, and human-review workflow. The authority to approve a campaign stays with a human-defined rule; the protocol carries it, signs it, and makes it auditable. + +This page maps the seven trust surfaces for CISOs, compliance reviewers, and procurement teams evaluating an AdCP deployment. For each surface: what AdCP provides, what it explicitly does not provide, and where to find the canonical detail. Live work across all seven surfaces is tracked under the [Trust, Identity, and Governance master issue (#3925)](https://github.com/adcontextprotocol/adcp/issues/3925). + +--- + +## Governance + +**What AdCP provides.** A three-party structure in which the agent that spends money is never the agent that approves the spend. The orchestrator proposes plans via [`sync_plans`](/dist/docs/3.0.13/governance/campaign/specification). An independently operated governance agent validates each plan against deployer-configured policies via [`check_governance`](/dist/docs/3.0.13/governance/campaign/specification) before the orchestrator proceeds. Every governance decision produces a [`get_plan_audit_logs`](/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs) entry — immutable, timestamped, and reproducible. + +**What AdCP does not provide.** `check_governance` is a seam, not an enforcer. A seller that has not configured a governance agent, or a misconfigured one, will not call `check_governance` at all — the protocol does not prevent a non-conformant seller from transacting. Regulated verticals (credit, insurance, employment, housing) get schema-level enforcement for the three categories named in AdCP 3.0 (`fair_housing`, `fair_lending`, `fair_employment`), but every other regulated category — political, pharmaceutical, gambling, financial promotions — relies on the governance-agent implementation. See the [Known Limitations — Governance](/dist/docs/3.0.13/reference/known-limitations#governance) section. + +→ [Governance overview](/dist/docs/3.0.13/governance/overview) · [Embedded human judgment](/dist/docs/3.0.13/governance/embedded-human-judgment) · [Policy registry](/dist/docs/3.0.13/governance/policy-registry) · [Annex III & Art 22 obligations](/dist/docs/3.0.13/governance/annex-iii-obligations) + +--- + +## Regulatory + +**What AdCP provides.** Structured fields that let a deployer discharge EU AI Act Annex III and GDPR Article 22 obligations: `plan.human_review_required` for human oversight (Art. 14), `policy_categories` and `restricted_attributes` for input-data governance (Art. 10), `get_plan_audit_logs` for automatic logging (Art. 12), `brand.data_subject_contestation` as a discoverable contestation contact point (Art. 22(3)). + +**What AdCP does not provide.** AdCP does not perform conformity assessment, DPIA, or contestation handling. Those remain the deployer's responsibility. The Warning block at the top of [Annex III & Art 22 obligations](/dist/docs/3.0.13/governance/annex-iii-obligations) is the authoritative framing. + +→ [Annex III & Art 22 obligations](/dist/docs/3.0.13/governance/annex-iii-obligations) + +--- + +## Privacy + +**What AdCP provides.** Schema-level PII controls: `hashed_email` and `hashed_phone` fields in `sync_audiences` reject cleartext. Structural privacy for the Trusted Match Protocol — separated code paths and schema prohibitions that prevent cross-party data leakage in TMP. No protocol-level PII transport elsewhere in the spec. + +**What AdCP does not provide.** Structural privacy applies only to TMP. Other domains rely on contractual confidentiality or per-session consent. AdCP does not carry a normative consent signal (IAB TCF, GPP, or equivalents). Cross-border transfer lawfulness is a contract and configuration property of the parties. See [Known Limitations — Security and Privacy](/dist/docs/3.0.13/reference/known-limitations#security-and-privacy). + +→ [Privacy Considerations](/dist/docs/3.0.13/reference/privacy-considerations) · [Trusted Match Protocol](/dist/docs/3.0.13/trusted-match/index) + +--- + +## Identity + +**What AdCP provides.** Discoverable identity for every party an agent transacts with. + +A house publishes [`brand.json`](/dist/docs/3.0.13/brand-protocol/brand-json) at `/.well-known/brand.json` declaring its corporate domain, brand portfolio with Keller-typed relationships (`master` / `sub_brand` / `endorsed` / `independent`), digital properties, authorized operators (agencies and partners by domain), house-level trademark claims, and per-agent JWKS URIs for verifiable signing keys. A publisher publishes [`adagents.json`](/dist/docs/3.0.13/governance/property/adagents) declaring which sales agents are authorized to sell which properties or signal catalogs, with publisher-attested `signing_keys` per agent. + +A bilateral verification chain ties the two together: brand.json's `properties[].relationship` MUST match adagents.json's `delegation_type` for the inventory path to be valid. The brand-protocol's mutual-assertion model (RFC [#3533](https://github.com/adcontextprotocol/adcp/issues/3533)) — child brands declare a `parent_house`, parent houses reciprocate via `brand_refs[]` — produces a five-state trust signal (`inline` / `mutual_assertion` / `one_sided_brand` / `one_sided_house` / `standalone`) that downstream consumers can act on directly. + +The [AAO Verified](/dist/docs/3.0.13/building/verification/aao-verified) mark, with `(Spec)` and `(Live)` qualifiers, continuously attests behavioral conformance through canonical test campaigns running against a seller's real ad-server integration. + +**What AdCP does not provide — three gaps to know.** + +First: **no aggregated public-registry identity claims.** brand.json carries house-level trademark claims and self-asserted brand relationships. It does not carry a generalized `identifiers[]` block aggregating claims against public registries that already verify the relevant facts — LEI / GLEIF for legal entity, USPTO / EUIPO / WIPO Madrid for trademark registrations, Verified Mark Certificates for CA-attested trademark→domain bindings, Wikidata Q-IDs for public identity, SEC EDGAR CIK for public-company identity. Identity claims defend against spoof and lookalike domains; they do not defend against compromise of the legitimate brand.json's hosting infrastructure — that threat is addressed by the Security surface. The aggregation RFC for this layer is tracked under the trust master issue. + +Second: **no buyer-side authorization primitive symmetric to `adagents.json`.** A brand cannot declare which buyer agents are authorized to transact on their behalf in a single discoverable place. The closest existing primitive is `brand.json`'s `authorized_operators[]`, which scopes by operator domain — not by agent endpoint, and with no signed binding from the brand to a specific buyer-agent JWKS. A compromised agent at an authorized operator's domain can transact unilaterally on every brand that lists that operator. RFC [#2307](https://github.com/adcontextprotocol/adcp/issues/2307) proposes a buyer-side agents.json for request signing; the broader authorization-layer gap is tracked alongside it. + +Third: **no operator/human KYC primitive in the protocol.** The protocol does not carry an attestation that a human or organizational operator has been identity-verified by a KYC provider (Persona, Stripe Identity, Onfido) or rooted in an authoritative IdP. KYC is punted to the membership and account layer; protocol-side, only the cryptographic facts (which key signed which message) are normative. See [Known Limitations — Authentication and Identity](/dist/docs/3.0.13/reference/known-limitations#authentication-and-identity). + +**Inventory and product claims.** When a buyer evaluates a [`get_products`](/dist/docs/3.0.13/media-buy/task-reference/get_products) response, the chain above establishes *who is authorized to sell* the inventory in question: the seller's domain resolves to an agent declared in the property owner's `adagents.json`, that file matches the property owner's `brand.json` under the bilateral verification rule, and the response itself is RFC 9421-signed by a key listed in `adagents.json`. What the chain does not establish is whether a specific product line — availability window, price, inventory volume — reflects reality at delivery time. Catalog accuracy is not protocol-attested: publishers do not sign individual product entries, and per-product attestation does not match how inventory operates in production. Delivery-time truth lives in measurement reports and the billing reconciliation flow ([#2391](https://github.com/adcontextprotocol/adcp/issues/2391)); a misbehaving authorized seller is remediated by the publisher revoking the `adagents.json` entry, not by an in-protocol claim check. This is the C2PA "claim-not-certification" posture applied to inventory: AdCP carries the authorization claim and makes it verifiable; it does not certify that the claimed inventory exists. + +→ [brand.json](/dist/docs/3.0.13/brand-protocol/brand-json) · [adagents.json and agent identity](/dist/docs/3.0.13/governance/property/adagents) · [AAO Verified](/dist/docs/3.0.13/building/verification/aao-verified) + +--- + +## Security + +**What AdCP provides.** RFC 9421 HTTP message signatures as the baseline mechanism for signed requests on mutating calls (normative in 3.1; allowed in 3.0 — see below) and for outbound webhook deliveries (with an opt-in HMAC fallback for receivers that require it). Idempotency keys on all state-changing operations to prevent replay attacks. Per-`(agent, account)` credential scoping to limit the blast radius of a stolen token. JWS-signed governance context traveling with every media buy, independently verifiable by the seller without trusting the buyer. + +**What AdCP does not provide — two limitations to know.** + +First: **signed requests are normative in 3.1, not 3.0.** AdCP 3.0 allows bearer-token auth on mutating calls. Requiring RFC 9421 signing on all mutating calls is tracked in [#2307](https://github.com/adcontextprotocol/adcp/issues/2307) for 3.1. Deployments that require signing today should enforce it at the platform layer and opt into AdCP Verified when the program launches with 3.1. + +Second: **signing keys are not yet anchored in a key-transparency log.** In 3.x, RFC 9421 buyer keys, governance JWS keys, and agent signing keys are ultimately rooted in each counterparty's own infrastructure — an attacker controlling a counterparty's CDN, DNS, or `/.well-known` path can serve attacker-controlled keys. TLS does not close this gap. AdCP 3.x delivers trust-on-first-use with continuity (multi-source cross-check, publication-delay windows, out-of-band rotation signalling) — detectably raising the bar, but not cryptographically closing it. A key-transparency layer is a 4.0 deliverable. See [Known Limitations — Authentication and Identity](/dist/docs/3.0.13/reference/known-limitations#authentication-and-identity) for the full description. + +→ [Security Model](/dist/docs/3.0.13/building/concepts/security-model) · [Security implementation reference](/dist/docs/3.0.13/building/by-layer/L1/security) + +--- + +## Provenance + +**What AdCP provides.** Right-use assertions in creative payloads, the `ai_generated_image` boolean flag for AI-generated imagery, and the `governance_context` JWS that traces each media buy back to the governance decision that authorized it. + +**What AdCP does not provide.** The `ai_generated_image` flag is a boolean marker, not a signed provenance assertion. There is no cryptographically signed provenance graph accumulating assertions as a creative passes through generation, editing, and adaptation. Interoperation with CAI/C2PA is tracked as future work. + +→ [Creative provenance verification](/dist/docs/3.0.13/governance/creative/provenance-verification) · [adagents.json and agent identity](/dist/docs/3.0.13/governance/property/adagents) + +--- + +## Disclosure + +**What AdCP provides.** The AI Disclosure page at [/docs/ai-disclosure](/dist/docs/3.0.13/ai-disclosure) names every surface where AgenticAdvertising.org uses AI, the models behind it, and how to request human review. `sync_plans` and `create_media_buy` carry the orchestrating agent's identity, making the AI-origin of each decision discoverable. + +**What AdCP does not provide.** No normative disclosure requirement for AI-generated ad content in the delivered creative. Disclosure obligations in served ads are the deployer's responsibility under applicable law (e.g., FTC guidance, EU AI Act Art. 50, DSA Art. 26). + +→ [AI Disclosure](/dist/docs/3.0.13/ai-disclosure) + +--- + +## For compliance reviewers + +These are the wire-level hooks deployers implement. They are seams, not guarantees — a non-conformant deployer can bypass them. + +| Control | Wire hook | AdCP role | +|---|---|---| +| Human oversight gate | `plan.human_review_required: true` + `check_governance` returning `APPROVED` | Provides the gate; deployer configures the threshold | +| Audit trail | [`get_plan_audit_logs`](/dist/docs/3.0.13/governance/campaign/tasks/get_plan_audit_logs) | Immutable, timestamped; deployer is responsible for retention | +| Contestation contact point | `brand.data_subject_contestation` | Discoverable endpoint; deployer operates the process | +| Request signing | RFC 9421 `Signature` / `Signature-Input` headers | Normative in 3.1; allowed in 3.0 | +| Governance attestation | JWS-signed `governance_context` on `create_media_buy` | Verifiable by seller independently | +| Regulated-category block | Schema-level rejection of `authority_level: agent_full` on `fair_housing`, `fair_lending`, `fair_employment` | Three categories only; others require governance-agent implementation | +| Brand identity declaration | `brand.json` `house`, `brands[]`, `authorized_operators[]`, house-level `trademarks[]` | Discoverable; deployer / ecosystem resolves against public registries | +| Mutual-assertion trust state | `brand.json` `parent_house` ↔ `brand_refs[]` (RFC #3533) | Five-state signal; deployer policy decides what's required | +| Agent identity | `brand.json` `agents[].jwks_uri`, `adagents.json` `signing_keys` | Verifiable signing keys; key-transparency layer is 4.0 | +| Behavioral verification | AAO Verified `(Live)` continuous attestation via canonical test campaigns | Issued and revoked by AAO; deployer trusts the mark, not the AdCP runtime | + +**See [Known Limitations](/dist/docs/3.0.13/reference/known-limitations)** for what AdCP explicitly does not do — that page is the adversarial read this one assumes you've taken. diff --git a/dist/docs/3.0.13/trusted-match/ai-mediation.mdx b/dist/docs/3.0.13/trusted-match/ai-mediation.mdx new file mode 100644 index 0000000000..1ddc9d28ab --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/ai-mediation.mdx @@ -0,0 +1,189 @@ +--- +title: How demand reaches AI assistants +sidebarTitle: AI mediation +testable: true +"og:image": /images/walkthrough/tmp-ai-01-black-box.png +description: "A mediation protocol for AI assistants — how demand finds conversational AI when the context can't be broadcast to every buyer." +"og:title": "AdCP — TMP for AI assistant mediation" +--- + +Priya looks at a StreamHaus AI assistant interface — the conversation is helpful but there's no way for advertisers to participate + +StreamHaus launched an AI assistant six months ago. Users ask about hiking trails, gear recommendations, trip planning — the kind of high-intent conversations advertisers would pay a premium to be part of. Priya watches the usage numbers climb and the ad revenue stay at zero. + +The assistant is a black box. There's no ad server. There's no impression. When a user asks "what trail shoes should I get for rocky terrain?", there's no bid request to broadcast — and even if there were, sending raw conversation context to every buyer on an exchange would leak user content at scale. + +This is the demand problem every AI platform faces: high-intent conversations with no standard way for buyers to participate. + +## The core design principle + +TMP solves this with a separation of concerns: the protocol decides **what** sponsored content is available and relevant, and the platform's LLM decides **how** to present it. The buyer never touches the user experience. The platform never builds custom ad logic. + +This is what makes TMP a mediation protocol rather than an ad injection system. Multiple buyer agents submit offers through a standard interface, and the platform retains editorial control over how — and whether — recommendations appear. + +## Step 1: The demand problem + +Every ad surface before AI assistants works the same way: a user loads a page, a bid request broadcasts context to buyers, an ad server renders a creative in a defined slot. AI assistants break all three assumptions: + +- **No defined slot** — sponsored content is woven into the response text +- **No bid request** — the conversation is private and ephemeral, not a crawlable URL +- **No ad server** — the platform's LLM generates the response + +There's no standard for this. No mediation layer, no protocol, no way for multiple buyers to compete for relevance in a conversation. Most AI platforms today partner with a single ad network, build proprietary sponsorship logic, or skip monetization entirely. + +Priya doesn't want to pick one ad network. She wants Sam at Pinnacle Agency, and every other buyer agent with relevant packages, to compete for relevance through a standard protocol. TMP addresses this by separating context evaluation from user identification. + +## Step 2: Bringing demand to the context + +The TMP Router hub from the frequency capping walkthrough now has a fourth connection — an AI assistant chat bubble icon joining web, mobile, and CTV + +On web, context radiates outward — the URL is public, the page content is crawlable, buyers evaluate it in parallel. AI assistants can't do that. The conversation is private, ephemeral, and contains user content that can't be shared openly. + +TMP's architecture was built for this constraint. Instead of broadcasting context outward, the router brings buyer agents inward — they see classified signals about the conversation, not the conversation itself. Priya registers the assistant as a new property on the same TMP Router that handles web and CTV: + +- **Property type**: `ai_assistant` +- **Placement**: `chat-inline-recommendation` — a conversational context where the LLM can incorporate sponsored recommendations + +The router fans out Context Match requests to all registered buyer agents in parallel. Sam's agent competes alongside other buyers — this is mediation, not a single-partner deal. + +## Step 3: Classified signals instead of bid requests + +A chat conversation about trail shoes sends classified context signals — topics, sentiment, keywords — to buyer agents, with a crossed-out person icon showing no user identity + +A user asks: "What trail shoes should I get for rocky terrain? I need good ankle support." + +Before the LLM generates its response, StreamHaus sends a **Context Match** request. A conversation turn isn't a URL — it's ephemeral, it contains user content, and it can't be sent to buyers. Instead, the platform sends **classified signals**: IAB topic codes, sentiment, keywords, and a natural language summary. The buyer evaluates relevance without seeing the user's actual words. + + + +```json +{ + "type": "context_match_request", + "request_id": "ctx-trail-shoes-01", + "property_rid": "01916f3a-f8cb-7000-8000-000000000051", + "property_type": "ai_assistant", + "placement_id": "chat-inline-recommendation", + "context_signals": { + "topics": ["596", "477"], + "taxonomy_source": "iab", + "taxonomy_id": 7, + "sentiment": "positive", + "keywords": ["trail shoes", "rocky terrain", "ankle support"], + "language": "en", + "summary": "User seeking trail shoe recommendations for rocky terrain with ankle support" + } +} +``` + +No `artifact_refs` — conversation turns are ephemeral. The `context_signals` carry the classified output. The `summary` field is especially useful for LLM-native buyers that evaluate relevance semantically. A platform that operates in a trusted execution environment could alternatively send the full conversation as an `artifact` — the publisher controls the disclosure level. + + + +Sam's buyer agent evaluates: "Trail shoes for rocky terrain — this matches Acme Outdoor's Trail Pro 3000 campaign." It responds with an offer that includes **text and structured data** — the raw material the LLM needs to generate a natural recommendation. + +Not every integration needs a full creative manifest. TMP supports a spectrum: + +| Integration level | What the offer contains | How the platform uses it | +|---|---|---| +| Activation only | `package_id` | Platform's ad server activates a line item (for platforms with traditional ad serving alongside AI) | +| Brand mention | `package_id` + `brand` + `summary` | LLM uses summary to generate a natural brand mention | +| Full recommendation | `package_id` + `brand` + `summary` + `creative_manifest` | LLM weaves product details from the manifest into the response | +| Catalog steering | `package_id` + `brand` + `creative_manifest` with catalog items | LLM recommends specific products from a synced catalog | + +Priya chose full recommendation for StreamHaus's assistant: + + + +```json +{ + "type": "context_match_response", + "request_id": "ctx-trail-shoes-01", + "offers": [ + { + "package_id": "pkg-outdoor-display", + "brand": { "domain": "acmeoutdoor.example" }, + "summary": "Trail Pro 3000 — ankle-height trail runner with rock plate, relevant to user's terrain needs", + "creative_manifest": { + "format_id": { "agent_url": "https://streamhaus.example", "id": "sponsored_recommendation" }, + "assets": { + "headline": { "content": "Built for rocky trails" }, + "body": { "content": "The Trail Pro 3000 has a full rock plate and ankle-height collar for technical terrain. Vibram outsole with 4mm lugs." } + } + } + } + ] +} +``` + +The `summary` helps the platform judge relevance before deciding whether to incorporate the offer. The `body` gives the LLM factual product details it can weave into a natural response. + + + +## Step 4: Frequency caps cross every surface + +A session token flows to the buyer agent — the same eligibility check as web, same frequency caps, same shared exposure store + +StreamHaus sends an Identity Match request with a session token. The buyer checks frequency caps, audience eligibility, and recency against the same shared exposure store that covers web and CTV. + +AI assistant users are often authenticated — logged in to the platform — which means the identity signal is typically stronger than web, not weaker. If this user saw the Trail Pro 3000 ad on StreamHaus's CTV app 30 minutes ago, the 2-hour recency window applies here too. The AI assistant doesn't get a free pass on frequency caps just because it's a different surface. + +## Step 5: The platform controls the experience + +The creative manifest text feeds into the LLM alongside the conversation context — the LLM generates a natural response that weaves in the product recommendation + +StreamHaus joins the Context Match and Identity Match responses. The Trail Pro 3000 offer is eligible. The offer's creative manifest becomes part of the LLM's generation context: + +> "For rocky terrain with good ankle support, you'll want a shoe with a rock plate and higher collar. The **Trail Pro 3000** is designed exactly for this — it has a full rock plate to protect against sharp rocks and an ankle-height collar for stability on technical terrain. The Vibram outsole with 4mm lugs gives you solid grip on loose surfaces. +> +> You might also look at..." +> +> *Sponsored recommendation from Acme Outdoor* + +The LLM didn't copy the creative manifest verbatim. It wove the product details into a natural recommendation that addressed the user's specific question. The platform applied its own editorial policy — a sponsored content label, natural integration, and continuation with non-sponsored alternatives. Regulatory requirements around AI-generated sponsored content (FTC disclosure, EU AI Act) are the platform's responsibility, enforced through their LLM integration, not the protocol. + +## Step 6: What this unlocks + +Three panels: a user receiving a relevant product recommendation in chat, Sam seeing his campaign reach a new high-intent surface, Priya seeing AI assistant revenue appear on her dashboard + +**The user** asked a question and got a useful answer. The Trail Pro 3000 recommendation was relevant — they asked about rocky terrain and ankle support, and that's what the product is built for. The sponsored label is transparent. The response continued with non-sponsored options. + +**Sam** reached a user in the moment they asked what to buy — intent doesn't get more specific than a direct question. The same packages running on web and CTV activated on the AI assistant. The creative manifest carried text instead of a banner, but Sam's buyer agent didn't need surface-specific logic. + +**Priya** monetized the AI assistant without proprietary sponsorship logic, without locking into a single ad network, and without compromising the user experience. Every buyer agent that speaks TMP can participate. The router handles the fan-out. StreamHaus controls editorial integration. Frequency caps from web and CTV carry over. And delivery reporting flows through the same measurement infrastructure as every other surface. + +| Without TMP | With TMP | +|---|---| +| Single ad network or no monetization | Any buyer agent can compete for relevance | +| Proprietary integration per advertiser | Standard protocol, open demand | +| No frequency caps across surfaces | Shared exposure store, same caps everywhere | +| Platform builds ad logic | Platform controls editorial integration only | +| Ad network controls the experience | Platform LLM controls presentation | + +## Multi-turn conversations + +Each user message triggers a fresh Context Match evaluation. The platform decides when to re-evaluate — recommended triggers are topic shift (detected by the platform's classifier), explicit product interest ("tell me more about..."), or session timeout (more than 5 minutes of inactivity). + +Platforms may cache the most recent Context Match response and skip re-evaluation for follow-up questions on the same topic. This reduces latency and provider load without affecting ad quality. + +When multiple buyers return offers for the same conversation turn, the platform ranks by relevance to the conversation — not by price. This is mediation, not an auction. + +An impression occurs when the LLM incorporates the creative manifest into its response. Follow-up questions about the recommended product ("where can I buy those?") are engagement events, not new impressions. + +**Latency.** TMP's sub-50ms round-trip is hidden within the LLM's generation time (typically 1-3 seconds). The platform sends the Context Match request while preparing the LLM prompt, so TMP adds no perceptible delay to the user experience. + +## Go deeper + + + + Technical reference for AI assistant integration — request/response formats, context signals, activation patterns. + + + How the two-operation model works across all surfaces, with concrete examples. + + + Cross-publisher frequency capping walkthrough — how TMP solves the execution gap. + + + Authoritative message types, field tables, and conformance requirements. + + diff --git a/dist/docs/3.0.13/trusted-match/buyer-guide.mdx b/dist/docs/3.0.13/trusted-match/buyer-guide.mdx new file mode 100644 index 0000000000..c05e6f6eaa --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/buyer-guide.mdx @@ -0,0 +1,246 @@ +--- +title: TMP for Buyer Agents +sidebarTitle: Buyer Guide +description: "How buyer agents integrate with TMP — responding to Context Match and Identity Match requests, structuring offers, and managing frequency caps." +"og:title": "AdCP TMP for Buyer Agents" +--- + +# TMP for Buyer Agents + +As a buyer agent, you receive Context Match and Identity Match requests from TMP Routers and respond with offers and eligibility decisions. You never send requests — publishers initiate every interaction through their router. + +## What You Build + +A buyer agent exposes two HTTP/2 endpoints under a single base URL — `POST /context` for Context Match and `POST /identity` for Identity Match. The router dispatches by path: + +| Message type | Receives | Returns | +|---|---|---| +| `context_match_request` | Page/content signals, placement, geo | Offers with creative manifests | +| `identity_match_request` | Seller agent URL, identity tokens, optional package ID list | Eligible package IDs + `serve_window_sec` | + +Each endpoint handles one message type. Both must respond in under 50ms. The router enforces this budget and will skip slow providers. + +The [adcp-go](https://github.com/adcontextprotocol/adcp-go) SDK provides Go types, request parsing, and response builders for both endpoints. + +## Prerequisites + +Before TMP requests arrive, your packages must exist. A media buy contains one or more packages — TMP operates at the package level. + +1. **Create media buys** via `create_media_buy` with the publisher's sales agent +2. **Sync creatives** via `sync_creatives` so the publisher has your creative assets +3. **Register as a TMP provider** so the publisher's router knows your endpoints + +The router learns about your packages from the publisher's deal history. You don't push package lists to the router. + +## Responding to Context Match + +The router sends you page context. You evaluate your active packages against that context and return offers for packages that match. + +```json +// Request you receive +{ + "type": "context_match_request", + "request_id": "ctx-8f3a2b", + "property_rid": "01916f3a-9c4e-7000-8000-000000000010", + "property_type": "website", + "placement_id": "article-sidebar", + "artifact_refs": [ + { "type": "url", "value": "https://streamhaus.example/articles/hiking-gear-2026" } + ], + "context_signals": { + "topics": ["550", "710"], + "keywords": ["hiking", "gear", "outdoor"], + "sentiment": "positive" + }, + "geo": { "country": "US", "region": "US-CO" } +} +``` + +**What you do:** + +1. Look up your active packages for this `property_rid` and `placement_id` +2. Evaluate each package's targeting against the context signals, geo, and artifact refs +3. Return offers for packages that match, each with a creative manifest + +```json +// Your response +{ + "type": "context_match_response", + "request_id": "ctx-8f3a2b", + "offers": [ + { + "package_id": "acme-outdoor-q2", + "brand": { "domain": "acmeoutdoor.example.com" }, + "summary": "Hiking gear seasonal promotion", + "creative_manifest": { + "format_id": { "agent_url": "https://streamhaus.example", "id": "sidebar_display" }, + "assets": { + "headline": { "content": "Trail-ready gear for every summit" }, + "image": { "url": "https://cdn.acme.example/hiking-hero.jpg", "width": 300, "height": 250 }, + "cta": { "content": "Shop now" } + } + }, + "price": { "amount": 12.50, "currency": "USD", "model": "cpm" } + } + ] +} +``` + +**What you never receive** in Context Match: user IDs, device IDs, session tokens, IP addresses, or cookies. You cannot identify the user. + +## Responding to Identity Match + +The router sends you the seller's `seller_agent_url` and one or more identity tokens. Use `seller_agent_url` to look up the active package set you have registered for that seller, resolve identities on whichever tokens you support, then check each package's eligibility rules against the resolved user. The publisher MAY also send `package_ids` to scope evaluation explicitly; when present, evaluate against the **intersection** of your registered active set and `package_ids`, and **silently drop** any IDs you don't recognize — both publisher modes (all-active and fuzzed/padded) rely on this behavior. Surfacing unknown IDs as errors would leak your registry membership back to the publisher. + +```json +// Request you receive +{ + "type": "identity_match_request", + "request_id": "id-9c4e", + "seller_agent_url": "https://publisher.example", + "identities": [ + { "user_token": "opaque-token-abc123", "uid_type": "publisher_first_party" }, + { "user_token": "ID5*7xYp...", "uid_type": "id5" } + ], + "package_ids": ["acme-outdoor-q2", "acme-winter-clearance", "acme-loyalty-retarget"], + "consent": { "gdpr": true, "tcf_consent": "CPxyz..." } +} +``` + +**What you do:** + +1. Resolve the tokens against your identity graph on whichever `uid_type`s you support. Entry order in the request is not semantically significant — apply your own preference order per the buyer's own preference order. All entries identify the same user, so the first successful resolution is sufficient. When multiple identity types are present, buyers SHOULD prefer opaque provider IDs (UID2, EUID, ID5, RampID) over strongly re-identifying tokens like `hashed_email`; this neutralizes scenarios where a misconfigured or compromised router forwards only the highest-risk token. +2. Check frequency caps: has this user exceeded the package's impression limit? +3. Check audience rules: is this user in the target audience? +4. Check suppression lists: should this user be excluded? +5. Return eligibility for each package + +```json +// Your response +{ + "type": "identity_match_response", + "request_id": "id-9c4e", + "eligible_package_ids": ["acme-outdoor-q2", "acme-loyalty-retarget"], + "serve_window_sec": 60 +} +``` + +Return only the package IDs that pass your eligibility checks. Packages not in the list are treated as ineligible. The `serve_window_sec` is a **per-package single-shot fcap**: after the publisher serves the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again. Default 60s, max 300s. This is not a router response cache TTL — see [The serve-window contract](#the-serve-window-contract). + +**What you never receive** in Identity Match: page URLs, content topics, keywords, article text, or any content signal. You cannot determine what the user is looking at. + +**Why you receive ALL packages**, not just the ones that matched in Context Match: this prevents you from inferring what content the user is viewing. If you only received packages that matched the hiking article, you'd know the user was reading about hiking. Receiving all packages preserves structural separation. + +## The Join Happens Publisher-Side + +You never see the combined result. The publisher's router: + +1. Intersects your Context Match offers with your Identity Match `eligible_package_ids` +2. Only activates packages that appear in both responses +3. Sets ad server targeting key-values for matched packages +4. The ad server makes the final rendering decision + +You have no role in this step. The publisher controls activation. + +## Frequency Cap Management + +Cross-publisher frequency capping is the primary use case for Identity Match. Cap policy and counting live in your **impression tracker**; the Identity Match service consumes only cap-fire signals at query time. The split: + +- **Impression tracker** receives pixel fires, decodes the TMPX token, and applies whatever fcap policies you maintain — counting impressions across whatever dimensions you cap on (package, campaign, advertiser, creative, line item) for each resolved user identity, with whatever windowing and dedup logic your policy engine uses. +- **On the impression that exhausts a cap**, the impression tracker writes a cap-fire entry — `(user_identity, package) capped until ` — into the Identity Match cap-state store. +- **Identity Match service** at query time excludes any package with a cap-fire entry against any of the request's identities from `eligible_package_ids`. + +The protocol does not constrain how you count impressions, where policies live, or how you dedup across identities. It only defines the boundary: cap-fire events flow into the cap-state store; the IdentityMatch service checks presence at query time. See [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation) for the boundary contract and the reference cap-state store. + +When an fcap rule changes — a window shortens or lengthens, a `max_count` rises or falls, a policy is paused or removed, a package is reassigned — you MUST re-evaluate the affected `(user_identity, package)` cap-state entries against the new policy and push the appropriate updates: **delete** entries for users no longer over-cap, **extend** (overwrite with a new `expire_at`) entries that are still over-cap but whose window changed. The cap-state store doesn't store counts and can't re-evaluate on its own; the buyer's policy owner is the source of truth. See [Policy updates and cap-state re-evaluation](/dist/docs/3.0.13/trusted-match/identity-match-implementation#policy-updates-and-cap-state-re-evaluation) for the event shapes. + +Because Identity Match runs across all publishers using TMP, a user who saw your ad on Publisher A will correctly show as over-frequency on Publisher B — even though you can't see which publisher sent the request. + +### How Buyers Learn About Exposures + +The `tmpx` field on the Identity Match response carries a TMPX token — an HPKE-encrypted blob containing the user's resolved identity tokens. The publisher substitutes `{TMPX}` into creative tracking URLs. When the ad serves, your impression pixel receives the encrypted token. Your impression tracker decrypts it, applies your fcap policy logic against the resolved identities, and (when a cap fires) writes a cap-fire entry to the Identity Match cap-state store. Most production deployments separate decode (synchronous, at intake) from policy evaluation and cap-state writes (asynchronous, behind a queue) for buffering. + +This gives you real-time per-user exposure signals without the publisher seeing user identity. + +See [TMPX Exposure Tokens](/dist/docs/3.0.13/trusted-match/specification#tmpx-exposure-tokens) for the encryption format and binary token structure, and [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation) for the cap-state store boundary contract. + +## Provider Registration + +Provider registration is an out-of-band process. After establishing a media buy via `create_media_buy`, coordinate with the publisher to provide your TMP base URL. This typically involves a commercial agreement and may require legal review, since the publisher will be sending content signals and identity tokens to your endpoints. The publisher then configures your provider entry in their router (see [router deployment](/dist/docs/3.0.13/trusted-match/router-architecture#deployment)). + +```json +{ + "provider_id": "acme-outdoor-us", + "endpoint": "https://us.tmp.acmeoutdoor.example/v1", + "context_match": true, + "identity_match": true, + "countries": ["US"], + "uid_types": ["uid2", "rampid", "id5"] +} +``` + +When you support `identity_match`, you MUST declare `countries` (which country codes you serve) and `uid_types` (which identity types you resolve). The router uses these to filter Identity Match fan-out — without them, the router cannot route requests to your provider. + +You can support either or both endpoints. Context Match only means contextual targeting without frequency capping. Identity Match only means the publisher evaluates context locally from your media buy's targeting rules and calls you only for frequency checks. Both means full TMP integration. + +### Health endpoint + +You SHOULD expose `GET /health` at your base URL. Return HTTP `200` with `{"status": "ok"}` when ready. The publisher's router uses this for pre-flight checks and monitoring. It is not called during request fan-out — only on a background interval. + +## Error Handling + +When your agent cannot evaluate a request, return an error response: + +```json +{ + "type": "error", + "request_id": "ctx-8f3a2b", + "code": "provider_unavailable", + "message": "Targeting data temporarily unavailable" +} +``` + +Common scenarios: +- **No matching packages**: Return an empty `offers` array (not an error). This is the normal case when your packages don't match the content. +- **Internal failure**: Return an error response. The router skips your provider and proceeds with other providers. +- **Timeout**: If you can't respond within the latency budget, the router skips you. No error response needed — the router handles this. + +## The serve-window contract + +The `serve_window_sec` field on Identity Match responses is a **per-package single-shot fcap** between the buyer and the publisher: + +- For each package in `eligible_package_ids`, the publisher MAY serve the user **at most one impression** on that package within `serve_window_sec` seconds. +- After the publisher has served one impression on each eligible package, the publisher MUST re-query Identity Match before serving any of those packages to the same user again. +- Multi-impression frequency capping (5/day, 100/month, etc.) is separate. It lives in your buyer-side state and is updated out-of-band via TMPX impression callbacks regardless of `serve_window_sec`. The serve window is the protocol-level throttle; multi-impression caps are buyer-internal policy. + +The router MAY apply an internal deduplication cache keyed by `{identities_hash, provider_id, package_ids_hash, consent_hash}` (see spec for canonical bytes), but the publisher's binding contract is the serve-window throttle, not the router's cache window. + +**Choosing a serve_window_sec value**: Default 60 seconds. Range 1–300. Anything longer than 300 makes per-package fcap too coarse for typical campaigns. Anything shorter than your IdentityMatch round-trip just adds load. 60 is a good default; tune downward if eligibility state shifts faster (close to a cap, audience just changed) or upward (max 300) if your IdentityMatch service is at load and the campaigns are tolerant of coarser fcap. + + +## Performance Requirements + +| Metric | Target | +|---|---| +| Agent-side processing | < 30ms p95 | +| End-to-end (publisher → router → agent → router → publisher) | < 50ms p95 | +| Availability | 99.9% | +| Error rate | < 0.1% | + +The 30ms agent-side budget accounts for network overhead between the router and your endpoint. The router tracks your latency percentiles and adaptively adjusts your timeout allocation. Consistently slow responses result in the router reducing your allocation or skipping your provider. + +## Measurement + +The publisher reports delivery via `get_media_buy_delivery`. Your agent queries delivery data to reconcile impressions, track pacing, and update frequency state. + +Buyers receive real-time per-user exposure signals via the `{TMPX}` macro. The Identity Match response includes an encrypted TMPX token that flows through creative tracking URLs to your impression pixel. Your cluster master decrypts the token and updates per-user frequency state in real time. `get_media_buy_delivery` provides aggregate delivery metrics for reconciliation and pacing — it is not the primary frequency input. + +## What's Different from OpenRTB + +| | OpenRTB | TMP | +|---|---|---| +| **You receive** | Full bid request (user + content + device) | Either content OR identity, never both | +| **You return** | Bid price | Offer (creative manifest) or eligible package IDs + serve window | +| **Auction** | Exchange runs auction | No auction — publisher joins locally | +| **Frequency** | Per-DSP only | Cross-publisher via Identity Match | +| **Integration** | Per-exchange SSP adapter | Two endpoints (context + identity), any surface | diff --git a/dist/docs/3.0.13/trusted-match/context-and-identity.mdx b/dist/docs/3.0.13/trusted-match/context-and-identity.mdx new file mode 100644 index 0000000000..e8eaca7426 --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/context-and-identity.mdx @@ -0,0 +1,231 @@ +--- +title: Context Match and Identity Match +description: The two operations at the heart of TMP, how they work, and how the publisher joins them at decision time. +"og:title": "AdCP Context Match and Identity Match" +--- + +# Context Match and Identity Match + +TMP defines two operations. They are independent by design — processed in separate infrastructure, carrying different data, answering different questions. The publisher joins their results to make the final activation decision. + +This page walks through both operations and the join using a concrete example. + +## The Scenario + +A retail publisher has three active AdCP media buys from a buyer agent. Each media buy contains packages: + +- **Package A**: Sponsored products — coffee brands, carousel format, available on search and category pages +- **Package B**: Homepage takeover — premium display, available on the homepage only +- **Package C**: Seasonal promotion — iced beverages, native format, available site-wide during summer + +A shopper searches for "best cold brew." The publisher's search results page loads. + +## Context Match + +The publisher sends a Context Match request to the TMP Router, which fans out to each buyer's agent. The request contains the page context. It contains no user identity and no package list — the buyer agent uses its synced package set for this placement. + +### What the publisher sends + +```json +{ + "type": "context_match_request", + "request_id": "ctx-7f3a", + "property_rid": "01916f3a-7b2c-7000-8000-000000000001", + "property_id": "retailer-web", + "property_type": "website", + "placement_id": "search-results-grid", + "artifact_refs": [ + { "type": "custom", "value": "search:beverages-coffee" } + ], + "context_signals": { + "topics": ["632"], + "keywords": ["cold brew", "iced coffee", "coffee beans"], + "sentiment": "positive", + "summary": "Shopper searching for cold brew coffee products" + }, + "geo": { "country": "US", "region": "US-CA" } +} +``` + +The buyer agent sees what the page is about — coffee, positive purchase intent, California — and evaluates its packages against that context. Note what is absent: no user ID, no device information, no session token, no IP address. + +### What the buyer responds + +The buyer agent evaluates each package against the context. Package B is homepage-only, so it does not match a search results page. Packages A and C match. The buyer returns an offer for each. + +```json +{ + "type": "context_match_response", + "request_id": "ctx-7f3a", + "offers": [ + { + "package_id": "pkg-A", + "brand": { "domain": "bluebottle.example.com", "brand_id": "blue_bottle" }, + "summary": "Cold brew coffee carousel — featuring top-rated blends", + "creative_manifest": { + "format_id": { "agent_url": "https://retailer.example.com", "id": "sponsored_carousel" }, + "assets": { + "items": { + "type": "catalog-asset", + "items": [ + { "gtin": "gtin-001", "image_url": "https://cdn.example.com/gtin-001.jpg" }, + { "gtin": "gtin-002", "image_url": "https://cdn.example.com/gtin-002.jpg" } + ] + } + } + }, + "macros": { + "sponsor_label": "Sponsored by Blue Bottle" + } + }, + { + "package_id": "pkg-C", + "summary": "Free shipping on iced beverages — summer promotion", + "price": { "amount": 0, "currency": "USD", "model": "flat" }, + "creative_manifest": { + "format_id": { "agent_url": "https://retailer.example.com", "id": "native_text" }, + "assets": { + "headline": { "content": "Free shipping on iced beverages" }, + "body": { "content": "Summer promotion — order any iced beverage and get free delivery." }, + "cta": { "content": "https://shop.example.com/promo/summer-iced" } + } + } + } + ], + "signals": { + "segments": ["coffee_enthusiast", "high_purchase_intent"], + "targeting_kvs": [ + { "key": "category_affinity", "value": "beverages" }, + { "key": "seasonal_relevance", "value": "high" } + ] + } +} +``` + +The buyer returns an offer per matched package. Each offer carries a `package_id` and optionally a `brand`, `price`, `summary`, `creative_manifest`, and `macros`. The `summary` gives the publisher enough to judge relevance. When the creative manifest is present inline, the publisher has everything needed to render. For large creatives (e.g., VAST video), the manifest references external assets via URLs rather than embedding them. The response also includes enrichment signals — audience segments and targeting key-values — that the publisher can pass to their ad server. + +### What Context Match never carries + +- User IDs (hashed or otherwise) +- Device identifiers +- Session tokens +- IP addresses +- Any data that could identify a specific user + +This is not a policy restriction. It is enforced by the router's context code path, which has no access to identity data — no shared memory, no shared state, no communication channel to the identity code path. [TEE attestation](/dist/docs/3.0.13/trusted-match/privacy-architecture) can make this separation independently verifiable. + +## Identity Match + +Separately — and with a random delay and random ordering, so the two requests can't be paired by timing or by which arrives first — the publisher sends an Identity Match request to the TMP Router, which fans out to each buyer's agent. The request contains the publisher's `seller_agent_url` and an identity token; the buyer resolves the active package set from `seller_agent_url`, or evaluates against the explicit `package_ids` list when the publisher sends one. The request contains no page context. + +### What the publisher sends + +```json +{ + "type": "identity_match_request", + "request_id": "id-9b2c", + "seller_agent_url": "https://publisher.example", + "identities": [ + { "user_token": "tok_hk82mfp1", "uid_type": "uid2" }, + { "user_token": "ID5*aB3xY...", "uid_type": "id5" }, + { "user_token": "a1b2c3d4e5f6...", "uid_type": "hashed_email" } + ], + "consent": { + "gdpr": true, + "tcf_consent": "CPx2XYZABC..." + }, + "package_ids": ["pkg-A", "pkg-B", "pkg-C"] +} +``` + +Each entry in `identities` is a `{user_token, uid_type}` pair. The publisher SHOULD include every token they have available — buyers resolve on whichever graph matches, and sending more tokens maximizes match rate without leaking additional page context. Each `user_token` comes from an existing identity provider (ID5, LiveRamp, UID2) or is publisher-generated. The `uid_type` tells the buyer which identity graph to resolve against, avoiding trial-and-error matching. The `consent` object carries the user's consent signal — buyers in regulated jurisdictions MUST NOT process the tokens without it. Tokens are opaque to the buyer — the buyer can map them to their own identity graph (if they have a match), but cannot reverse them to PII or correlate with any page context. + +Note what is absent: no URL, no search query, no content signals, no topic IDs. The buyer agent evaluates this request based purely on user identity and package eligibility. + +### What the buyer responds + +```json +{ + "type": "identity_match_response", + "request_id": "id-9b2c", + "eligible_package_ids": ["pkg-A", "pkg-B"], + "serve_window_sec": 60, + "tmpx": "k1.dG1weC1leGFtcGxlLWVuY3J5cHRlZC10b2tlbi4uLg" +} +``` + +The buyer reports that this user is eligible for packages A and B. Package C is absent — the user is not eligible. The publisher does not need to know why — frequency capping, audience mismatch, and other disqualification reasons are buyer-internal. + +The `serve_window_sec: 60` tells the router: "Cache this for 60 seconds." The router uses this cached eligibility to fill whatever placements exist — a single slot, a CTV ad pod, or a page with multiple ad units — without re-querying the buyer. The publisher decides how to allocate across placements. + +### What Identity Match never carries + +- Page URLs +- Content hashes +- Search queries +- Topic IDs +- Content ratings +- Any data that could identify what the user is looking at + +This is enforced by the router's identity code path, which has no access to context data — no shared memory, no shared state, no communication channel to the context code path. TEE attestation can make this separation independently verifiable. + +## The Publisher-Side Join + +The publisher now has two independent responses. They join them on their own infrastructure — neither the buyer agents nor the router see the joined result. + +``` +For each offer from Context Match: + + pkg-A: + Context says: offer for cold brew carousel (creative manifest inline) + Identity says: eligible (in eligible_package_ids) + → Accept the offer. Render using the inline creative manifest. + → Apply "coffee_enthusiast" segment to ad server targeting. + + pkg-C: + Context says: offer for iced beverage promotion + Identity says: not eligible (absent from eligible_package_ids) + → Skip this offer for this user. + +Result: Accept Package A. +Set targeting KVs: category_affinity=beverages, seasonal_relevance=high. +``` + +The publisher already knows how to render Package A — it maps to a sponsored carousel slot. The inline creative manifest carries the catalog items and assets needed. The publisher handled the rest. + +## The Offer Model + +TMP's response model is the offer. An offer carries a `package_id` (required) and optional fields: `brand`, `price`, `summary`, `creative_manifest`, and `macros` (a key-value map for dynamic values). + +**Simple case (GAM/Prebid)**: The offer carries `package_id`. The publisher flows `package_id` via `targeting_kvs` signals to GAM for line item matching. The `macros` map carries dynamic values (e.g., sponsor labels, promo text) that GAM can insert into the creative at render time. + +**Rich case (AI assistants, dynamic retail)**: The offer includes a `summary` ("50% off cold brew — recipe integration") so the publisher can judge relevance, and an inline `creative_manifest` with everything needed to render. For large creatives (e.g., VAST video), the manifest references external assets via URLs rather than embedding the full payload. + +**Dynamic brands**: When the product supports `dynamic_brands`, the buyer can include a `brand` on the offer, selecting from their portfolio at match time rather than being locked to a pre-configured package brand. + +**Variable pricing**: When the product supports variable pricing, the buyer can include a `price` on the offer. + +The creative manifest carries catalog items, text, images, and everything else needed for rendering. This reuses the existing CreativeManifest schema. + +Per-user exposure tracking flows through the TMPX macro — an encrypted token from Identity Match that the publisher substitutes into creative tracking URLs. The buyer's impression pixel decrypts the token and logs per-user exposures in real time. Aggregate delivery reporting via `get_media_buy_delivery` provides reconciliation and pacing data. + +## Enrichment Signals + +Context Match responses can include enrichment signals alongside package activation: + +- **Segments**: Audience or contextual segments (e.g., "coffee_enthusiast", "high_purchase_intent") that flow into the publisher's ad server as targeting signals. +- **Targeting key-values**: Arbitrary key-value pairs (e.g., `category_affinity=beverages`) that the publisher can use for line item targeting, reporting breakdowns, or real-time decisioning. + +Enrichment signals are additive — they are not tied to specific packages. A buyer agent might return enrichment signals even when it activates no packages, providing value as a data provider rather than a demand source. + +This is how existing RTD (Real-Time Data) modules work today. TMP unifies enrichment and package activation into a single protocol, so a buyer agent can do both in one response. + +## Package List Management + +Identity Match sends ALL active package IDs for a given buyer — not just those that matched in Context Match. This is intentional: it prevents the buyer from correlating which packages matched the page content. If the buyer only received packages that matched a hiking article, they'd know the user was reading about hiking. + +Package lists update when new media buys are created via `create_media_buy`. The router maintains the active package list per buyer, derived from the buyer's media buy history with the publisher. + +In Context Match, the optional `package_ids` field can narrow evaluation when the publisher has a specific reason — for example, CTV pod composition where only video packages apply. This does not affect Identity Match: when Identity Match sends `package_ids`, the composition must be independent of the current placement (all-active or fuzzed), not the placement-specific subset. + +Stale packages from expired or cancelled media buys should be removed from the active list within 1 hour. The router is responsible for this cleanup. diff --git a/dist/docs/3.0.13/trusted-match/data-protection-roles.mdx b/dist/docs/3.0.13/trusted-match/data-protection-roles.mdx new file mode 100644 index 0000000000..5ee7c43c1a --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/data-protection-roles.mdx @@ -0,0 +1,239 @@ +--- +title: Data Protection Roles +description: How TMP's architecture maps to GDPR controller/processor roles — what each party determines, what data each party holds, and where the risks are. +"og:title": "AdCP TMP Data Protection Roles" +--- + +# Data Protection Roles + +This page maps TMP's architecture to GDPR data protection roles (controller, processor, joint controller). It explains what each participant can and cannot determine about individuals, where the architectural boundaries support a processor position, and where they don't. + +This is an architectural analysis, not legal advice. Organizations should consult qualified data protection counsel for their specific circumstances. Familiarity with the [TMP privacy architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture) is assumed — concepts like structural separation, TEE attestation, package set decorrelation, and temporal decorrelation are defined there. + +## Verdict at a Glance + +| Participant | Role | Confidence | Where the risk sits | +|---|---|---|---| +| **TMP Router** | Processor | High (architectural) | Operator deployment integrity. Mitigated by TEE attestation. | +| **Buyer agent** | Processor | Conditional (operational) | Proprietary scoring, cross-advertiser data combination, audience construction, cross-publisher exposure accumulation. | +| **Publisher** | Controller | Unchanged | Consent collection; configuring data processors; the final serve decision. | +| **SSP / wrapper performing the join** | Joint controller or processor for the publisher | Conditional | Whoever performs the context+identity join inherits controller responsibility for that step. | +| **Identity provider** | Out of scope for TMP; controller for token issuance | Varies by provider | Token scope and graph behavior (publisher-first-party vs. deterministic cross-site vs. probabilistic graph) materially changes the publisher's risk. | +| **Measurement / attribution** | Out of scope for TMP | n/a | Post-impression flows reintroduce controller analysis that TMP does not address. See [Out of Scope](#out-of-scope-post-impression-flows). | + +## Background: Controller vs. Processor + +Under GDPR, a **controller** determines the purposes and means of processing personal data. A **processor** processes personal data on behalf of a controller. A **joint controller** jointly determines purposes and means with one or more other controllers. + +The distinction matters because controllers carry heavier obligations: legal basis for processing, data subject rights, DPIAs, and direct liability. The key question is not "who touches the data" but "who decides what happens with it." + +In advertising, the risk of being deemed a controller increases when an intermediary: + +- Decides to show an ad to a specific person based on something it knows or infers about them +- Builds or populates a segment by determining that an identifier has a characteristic +- Combines third-party segment data with other data to create a new profile +- Uses the data for any purpose beyond the specific campaign instruction + +The risk decreases when the intermediary: + +- Processes data under instructions from a party that has a direct relationship with the data subject or data provider +- Does not hold title to any segment or audience data +- Does not use the data independently or for its own purposes +- Does not combine data across sources to create new mappings + +## Why TMP's Posture Is Unusual + +The buyer agent's processor position is unusual relative to how the ad tech industry typically operates. Most DSPs function as joint controllers or independent controllers for their own optimization purposes, even when their MSAs claim processor status. The IAB Europe TCF framework reflects this by assigning separate purposes and legal bases to each vendor in the chain. + +TMP's architecture *enables* a buyer agent processor position that traditional DSPs cannot credibly claim, because: + +- The buyer never receives user identity and page context together (DSPs receive both in every bid request) +- The buyer does not set per-impression prices based on identity (DSPs submit bid prices informed by user data) +- The buyer returns binary eligibility, not a scored bid (DSPs return a price that encodes their valuation of the user) + +Whether a buyer agent operates within that envelope is a contractual and operational question, not an architectural one. Most buyer-side organizations will need to make explicit choices to stay inside it. A buyer agent that introduces proprietary scoring, cross-advertiser data combination, or independent audience construction erodes the architectural advantage and may need to assess its role as a joint controller regardless of what the protocol enables. + +## The TMP Router: Processor + +The TMP Router is infrastructure. It does not make targeting decisions, build profiles, or evaluate users. It receives requests from the publisher and fans them out to buyer agents. It receives responses and merges them. It returns the merged result to the publisher. + +**What the router sees on the Context Match path:** + +- Content signals (topics, keywords, sentiment) +- Placement identifiers +- Geographic context (coarse) +- No user identity of any kind + +**What the router sees on the Identity Match path:** + +- Opaque user tokens +- Package identifiers +- Consent signals +- No page context of any kind + +The two paths are structurally separate: no shared memory, no shared state, no communication channel. The router cannot associate a user token with a page URL because no single code path ever holds both. See [Privacy Architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture) for the enforcement mechanism. + +**What the router does not do:** + +- Evaluate whether a user should see an ad +- Check frequency caps or audience membership +- Build or store user profiles +- Combine context data with identity data +- Make pricing decisions +- Retain data beyond the request/response lifecycle + +The router is a processor acting on the publisher's instructions (which providers to call, which properties to serve). It processes personal data (opaque user tokens) solely to deliver them to buyer agents and return the result. + +> **Bottom line:** The router can credibly claim processor status. With TEE attestation, this is independently verifiable; without TEE, it depends on operator integrity and code audit. + +## The Buyer Agent: Processor Conditional on Operational Discipline + +TMP architecturally constrains what the buyer receives (no context with identity) and what it returns (eligible package IDs, nothing more). It does not constrain what the buyer does internally with the tokens it sees, the exposure histories it builds, or the proprietary models it runs. + +The processor position is therefore conditional, not architectural. It depends on the buyer agent's operational choices and the DPAs that govern them. Most buyer-side organizations will need to make deliberate choices to stay inside the envelope TMP enables. + +**What the architecture provides:** + +- The buyer never receives page context with identity. Identity Match requests carry no page URL, no content signals, no topic IDs. +- The buyer does not set price per impression. The Identity Match response is eligible package IDs and a cache TTL — no price, no bid, no scored response. +- The buyer does not make the serve decision. The publisher performs the join (or delegates it — see [the SSP question](#the-publisher-and-the-ssp-join)). + +**Where the processor position erodes:** + +- **Proprietary eligibility scoring.** A buyer agent that uses ML models to score eligibility — even models trained only on advertiser data — is determining means of processing. The line between "applying advertiser criteria" and "operating an optimization engine" is the line between processor and joint controller. A buyer agent that does *not* run a proprietary optimizer is uncompetitive against existing DSPs. This is the base case, not the edge case. +- **Cross-advertiser data combination.** A buyer agent serving multiple advertisers must keep their data isolated. Combining segment membership across advertisers to enrich profiles is controller behavior. +- **Audience construction from observation.** Applying an advertiser-provided audience list (processor) is different from constructing an audience by observing behavior (controller). See [Risks requiring DPA scrutiny](#risks-requiring-dpa-scrutiny). +- **Cross-publisher exposure histories.** Cross-publisher frequency capping is the headline TMP use case *and* the headline data protection exposure. Even without context, an exposure history tied to a user token across many publishers constitutes a behavioral profile under CJEU jurisprudence. The protocol does not eliminate this — it isolates it. + +> **Bottom line:** TMP enables a buyer-agent processor position; it does not enforce one. The DPA between the advertiser and the buyer agent must specify what the buyer may do with the tokens it sees, how exposure histories are bounded, and how proprietary models interact with advertiser data. + +## The Publisher and the SSP Join + +The publisher is the first party. They have a direct relationship with the user. They collect consent. They hold both context (what the user is viewing) and identity (who the user is). TMP does not change the publisher's controller status. + +The publisher's controller responsibilities include: + +- Collecting and transmitting consent signals in Identity Match requests +- Ensuring user tokens are opaque and not reversible to PII by buyer agents +- Performing (or delegating) the join between context and identity locally +- Applying consent logic before serving +- Configuring which providers the router calls (data processor selection) +- Selecting which identity provider's tokens to use (a controller-level decision — see [Publisher configuration choices](#publisher-configuration-choices)) + +**The join in practice.** "The publisher performs the join locally" is correct in principle and incomplete in practice. Most publishers operate ad servers (Google Ad Manager, Kevel, Equativ) that do not natively expose primitives for "join two real-time API responses with consent logic before serving." Publishers using GAM will typically need a header bidder wrapper, a Prebid module, or an SSP shim to perform the join. Many will outsource it to their SSP (Magnite, PubMatic, Index Exchange, OpenX). + +When the join is delegated, the SSP or wrapper inherits controller responsibility for the join step itself. The publisher's DPA with the SSP must reflect this: the SSP becomes a joint controller for the join (or processor specifically scoped to the join) depending on how their broader services are characterized. A publisher who assumes "TMP made me a processor" by virtue of delegating the join has misread the architecture. + +> **Bottom line:** Publisher remains controller. If the join is delegated to an SSP or wrapper, that party becomes a joint controller for the join step and must be addressed in the DPA chain. + +## Pricing and Real-Time Decisions + +A critical factor in controller/processor analysis is whether an intermediary makes real-time pricing or bidding decisions based on user identity. + +**TMP does not include real-time pricing based on identity.** Here is where pricing occurs in the protocol: + +| Decision | When | Based on | Where | +|---|---|---|---| +| Package price | Media buy negotiation (offline) | Product catalog, volume, terms | Buyer agent and publisher, before any user is evaluated | +| Context Match offer price | Request time | Content context only — no user identity | Context Match path (no identity data available) | +| Identity Match eligibility | Request time | Frequency caps, audience membership | Identity Match path (no context data available) | +| Final serve decision | After both responses return | Publisher (or SSP) joins context + identity + consent | Publisher infrastructure or delegated to SSP | + +No participant in TMP sets a price based on "this specific user on this specific page." The Context Match path may include variable pricing (a buyer might value hiking content more than general content), but this is based on content, not identity. The Identity Match path determines eligibility, not price. + +The pre-negotiated pricing model reduces the link between identity and economic outcomes, but does not eliminate it entirely. When the publisher (or SSP) joins context match offers with identity match eligibility and activates a package, the economic result is that this user on this page saw this ad at this price. A regulator may assess the system holistically rather than examining individual protocol messages. The architectural distinction is that no single intermediary makes a combined user-plus-context pricing decision. + +This is distinct from OpenRTB, where a bidder receives user identity and page context together and submits a per-impression bid price. In that model, the bidder *can* make a real-time pricing decision based on who the user is combined with what they are viewing. Whether it does depends on the campaign — many bidders price primarily on context and use identity only for frequency capping, which is closer to a processor pattern. The structural concern is that OpenRTB *enables* this combination, and the processor position depends on contractual constraints rather than architectural ones. TMP separates these concerns at the protocol level. + +## Out of Scope: Post-Impression Flows + +**TMP covers real-time decisioning only.** It does not specify how delivery reports, conversion events, attribution, or measurement data flow after an impression is served. + +This matters because every campaign needs conversion tracking, view-through attribution, MMM inputs, and incrementality measurement. These flows route impression-level data — typically including user tokens, creative IDs, timestamps, and conversion events — to attribution systems (CM360, Innovid, Flashtalking), DSP-native attribution stacks, and measurement vendors (DV, IAS, iSpot, Nielsen). These vendors are typically controllers or joint controllers for the data they receive. + +If operators plug their existing impression-log pipelines into TMP-decisioned campaigns, they will have built privacy-preserving real-time decisioning attached to a wide-open post-impression pipe. The architectural protection in front does not reach the back. + +Two paths address this: + +1. **Scope-limit deployment.** Treat post-impression flows as a separate data protection question, governed by the existing DPAs between the advertiser, the measurement vendors, and any clean room operators. TMP is not the lever for solving attribution privacy; existing frameworks (clean rooms, measurement APIs, aggregated reporting) are. +2. **Adopt compatible attribution.** Use buyer-blind conversion APIs, publisher-side conversion logs joined in a clean room, or aggregated measurement systems that maintain the same separation discipline as TMP itself. This is an emerging area; AdCP does not yet specify a "TMP-compatible attribution" pattern, though one may follow. + +DPOs evaluating TMP adoption should treat post-impression flows as a distinct workstream and not assume the protocol's separation properties extend to them. + +## Comparison: AXE (Deprecated) vs. TMP + +[AXE](/dist/docs/3.0.13/media-buy/advanced-topics/agentic-execution-engine), TMP's predecessor, had a weaker data protection posture: + +| | AXE | TMP | +|---|---|---| +| What the real-time endpoint received | Full OpenRTB-style request: user identity + page context + device signals | Separate requests: context OR identity, never both | +| Who saw user + context together | The AXE endpoint operator | Only the publisher (or delegated SSP), as first party | +| Profile construction risk | Operator could theoretically build browsing profiles | Architecturally constrained for the router (no code path holds both signals); buyer-side correlation impeded by [decorrelation mechanisms](/dist/docs/3.0.13/trusted-match/privacy-architecture) but depends on publisher compliance with SHOULD-level requirements | +| Pricing model | Opaque segment decisions fed ad server targeting | Pre-negotiated package prices, no per-impression bidding | +| Separation enforcement | Trust and contract | Code structure (auditable) or TEE attestation (verifiable) | + +AXE's design meant the endpoint operator (typically the orchestrator) received user identity and page context in the same request. The operator's processor position depended on contractual commitments not to misuse the combined data. TMP replaces this with structural separation — the router cannot misuse data it never holds together. + +## Comparison: RTD Modules (Prebid Real-Time Data) + +RTD modules are vendor-specific Prebid extensions that enrich bid requests at auction time. Each module sends the full OpenRTB BidRequest (2-10KB) to a vendor endpoint, which returns enrichment data (audience segments, contextual classifications, brand safety scores). + +**Data protection concern:** RTD modules send user identity and page context together to each vendor. The vendor's processor position depends on contract, not architecture. The cumulative exposure is significant: a publisher using 5 RTD modules sends the full user-plus-context payload to 5 different vendor endpoints per impression. Each vendor's processor position is independently contractual. + +TMP replaces vendor-specific RTD modules with a standardized protocol that enforces separation. Instead of sending everything to every vendor, TMP sends context to the context path and identity to the identity path. The result is the same (packages activate or don't), but the data exposure is structurally minimized. + +## Risks Requiring DPA Scrutiny + +These are operational risks that DPAs must address. The architecture does not constrain them. + +**1. Cross-publisher exposure histories** (the headline buyer-agent risk). + +Buyer agents that track cross-publisher frequency maintain exposure histories tied to user tokens. Even without page context, this constitutes a behavioral profile: how many properties this user appears on, how frequently, across which publisher categories. The CJEU's *Meta Platforms* decision (Case C-252/21) established that combining data across services can constitute controller-level processing even without deep profiling. + +This is not a footnote risk — it is the headline data protection exposure of the headline TMP use case. The protocol does not eliminate it; it isolates it. DPAs between advertisers and buyer agents must specify the legal basis, retention period, purpose limitation, and erasure flow (Article 17 rights apply: a user exercising erasure means the buyer's exposure history tied to that token must be deletable). + +**2. Retargeting audience construction.** + +There is a distinction between *applying* an audience (checking a user token against an advertiser-provided list — processor pattern) and *building* an audience (determining that a user token has a characteristic based on observed behavior — controller pattern). If a buyer agent receives conversion events or site visit signals and constructs a retargeting pool, it is building an audience. + +How retargeting audiences enter the system matters. Advertiser-provided lists that the buyer checks mechanically support a processor position. Buyer-constructed audiences from observed behavior do not. + +**3. Proprietary eligibility scoring.** + +A buyer agent that uses ML models to score eligibility — even models trained only on advertiser data — is determining means of processing. DPAs should specify what models the buyer may run, what data trains them, and how their outputs are constrained. + +**4. Measurement and attribution flows.** + +Covered in [Out of Scope](#out-of-scope-post-impression-flows). Treat as a distinct workstream from TMP itself. + +## Publisher Configuration Choices + +These are publisher-side configuration decisions with data protection implications. Each is a controller-level decision the publisher makes. + +**1. Identity provider selection.** + +TMP consumes tokens from identity providers, but the providers are not interchangeable. Different graph behaviors create fundamentally different risk shapes: + +| Token type | Risk shape | Examples | +|---|---|---| +| Publisher-first-party | No cross-site linkage. Lowest risk profile. | `publisher_first_party` (per-publisher hashed identifiers) | +| Deterministic cross-site | Same user resolves to same token across sites and devices. Enables cross-site profiling. | UID2 (operated by The Trade Desk, primarily for buy-side use), ID5 | +| Probabilistic / commercial graph | Provider operates an identity graph that resolves tokens to PII inside the provider's walls. | RampID (LiveRamp) | + +The choice of identity provider is itself a controller-level decision. Selecting a deterministic cross-site token expands the buyer agent's cross-publisher correlation surface. Publisher DPAs and consent flows should reflect the provider's specific posture, not treat all `uid_type` values equivalently. + +**2. Context Match with full artifacts.** + +When a publisher sends full content (`artifact` field) rather than classified signals (`context_signals`), the buyer agent receives the actual content. `context_signals` (pre-classified topics, sentiment, keywords) is the privacy-preserving baseline. Full artifacts exist for cases where the buyer needs to evaluate content directly (e.g., AI assistant conversations where classification alone is insufficient). + +**3. Cache semantics.** + +Identity Match responses include a `ttl_sec` caching contract. During the cache window, the router returns cached eligibility without re-querying the buyer. Cached eligibility is personal data (it's tied to a user token). The [specification](/dist/docs/3.0.13/trusted-match/specification) allows TTLs up to 86,400 seconds (24 hours) with a recommended clamp at 3,600 seconds. Routers should enforce short TTLs, must not retain cached data beyond expiry, and must not use cached eligibility for any purpose other than responding to subsequent Identity Match requests for the same token. + +**4. Variable pricing on context.** + +The Context Match offer can include an `OfferPrice`. Because Context Match carries no identity, this is contextual pricing — not per-user pricing. However, if a publisher's `context_signals` are specific enough to identify an individual (e.g., a unique AI conversation summary), the contextual path could carry de facto identity. Publishers should ensure `context_signals` do not contain PII or uniquely identifying content. + +**5. Join delegation.** + +If the publisher delegates the context+identity join to an SSP, header bidder wrapper, or third-party module, that party becomes a joint controller for the join step. The publisher remains controller for the broader serve decision but must address the delegate's role in the DPA chain. diff --git a/dist/docs/3.0.13/trusted-match/execution-gap.mdx b/dist/docs/3.0.13/trusted-match/execution-gap.mdx new file mode 100644 index 0000000000..9d6cbc382a --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/execution-gap.mdx @@ -0,0 +1,93 @@ +--- +title: The Execution Gap +description: Why existing protocols fail at real-time execution, and why TMP takes a matching approach instead of an auction approach. +"og:title": "AdCP The Execution Gap" +--- + +# The Execution Gap + +A buyer agent discovers inventory through `get_products`, negotiates a deal through `create_media_buy`, and later measures delivery through `get_media_buy_delivery`. The deal exists. Packages are defined with agreed pricing, targeting, and budgets. + +Then a user visits a page. Or opens an app. Or asks an AI assistant a question. + +What happens? + +## What Happens Today + +The answer depends entirely on the surface — and on each surface, it's ad hoc. + +### Web (with ad server) + +The publisher's ad operations team manually creates line items or PMP deals in their ad server (GAM, Kevel, FreeWheel) that correspond to each AdCP package. When a page loads, the ad server evaluates its own targeting rules to decide which line items are eligible. If the publisher uses Prebid Server, vendor-specific RTD (Real-Time Data) modules can inject signals into the auction — but each module speaks its own API, sends the full OpenRTB BidRequest (~2-10KB of JSON), and operates independently of the AdCP deal structure. + +The result: packages negotiated through AdCP are activated through a separate, manual process. The deal and the execution are disconnected. + +### AI Assistants + +There is no standard mechanism. AI platforms (ChatGPT, Snap AI, Reddit chat, character.ai) that want to monetize conversations have no protocol for asking buyer agents "which of your packages match this conversation?" Each platform builds its own ad serving logic, its own buyer integrations, and its own targeting system — or partners with a single ad network and delegates the entire problem. + +### Mobile Apps + +Mediation SDKs (AppLovin MAX, ironSource, Google AdMob) handle the decision: which network deal should serve for this user and placement? But mediation operates on its own deal registry, disconnected from AdCP packages. The app developer configures waterfall priorities or auction rules inside the mediation dashboard. There is no protocol path from "AdCP package exists" to "mediation layer activates it." + +### CTV + +Pod servers compose ad breaks from multiple deals. Each deal is configured in the broadcaster's ad server with targeting rules, competitive separation constraints, and creative rotation logic. Pod composition is a complex, surface-specific problem. There is no standard way for a buyer agent to say "activate my package for this pod, and prefer the 15-second cutdown." + +### Retail Media + +Retailers manage sponsored product placements through internal recommendation engines. When a shopper searches or browses a category, the retailer's algorithm decides which sponsored products to show. Buyer agents have no real-time input into this decision beyond the initial deal terms. There is no protocol for saying "for this search context, prefer these GTINs and exclude these promotions." + +## Why OpenRTB Is Wrong for This + +OpenRTB was designed for a specific scenario: an ad exchange runs an auction among bidders who have no pre-existing relationship. It solves the cold-start problem — how do strangers trade ad inventory in real time? + +But when packages are pre-negotiated through AdCP, the parties are not strangers. They have a deal. The question at execution time is not "who will bid the highest?" but "which of our agreed packages should activate for this context?" + +OpenRTB is wrong for this in three specific ways: + +**It bundles user identity with page context.** Every OpenRTB bid request sends user IDs, device fingerprints, IP addresses, and page URLs in a single object. This is a structural privacy failure. Buyers can — and do — use this data to build cross-site browsing profiles. No amount of consent management fixes the architecture; the data travels together by design. + +**It forces auction semantics.** OpenRTB assumes every impression is a competitive auction. But pre-negotiated deals are not competitive — the price is already agreed. Forcing a deal through auction machinery means building PMP logic, deal ID matching, and floor price enforcement on top of a protocol that was designed for open competition. The tail wags the dog. + +**It is heavyweight.** A typical OpenRTB bid request is 2-10KB of JSON, carrying the full device object, user object, site/app object, and impression array. When execution only needs to know "which packages match this page context?" most of that payload is wasted bandwidth and wasted parsing time. + +| What execution needs | What OpenRTB sends | +|---|---| +| Page content signals | Full site object + referrer chain + keyword lists | +| Available packages | Nothing — buyer must infer from deal IDs | +| Content classification | Partial — buyer often reclassifies independently | +| User eligibility | Raw user IDs, device IDs, IP address, GPS | +| Result: package activation | Result: bid with price, creative URL, tracking pixels | + +## Why Auctions Are a Resolution Strategy, Not the Protocol + +A related instinct is to build a new auction protocol — faster, lighter, more privacy-aware — and use it for real-time execution. CloudX's OpenAuction takes this approach: open-source auction logic running in a TEE (Trusted Execution Environment) with encrypted bids and attestation proofs. It is well-engineered. + +But auctions are the wrong primitive for most real-time ad decisioning: + +**Filtering, not competition.** "Which of my pre-bought packages apply to this context?" is a filtering operation. The buyer evaluates the available packages against their campaign targeting and budget. There is no adversarial competition — the buyer is selecting from their own deals. + +**Steering, not bidding.** "Within this package, which catalog items or creative variants should I prefer?" is a steering operation. The buyer expresses preferences within the bounds of an agreed deal. There is no price to compute. + +**Eligibility, not ranking.** "Is this user frequency-capped or high-intent for this package?" is an eligibility check. The buyer looks up the user's status. There is no bid to rank. + +An auction is one valid resolution strategy when multiple packages from multiple buyers compete for the same placement. But making the auction the protocol forces every surface — including AI assistants selecting sponsored content by relevance, retailers populating carousels by product fit, and CTV broadcasters composing pods by editorial judgment — into a bidding paradigm. + +TMP takes a different approach: return the matching packages, and let the publisher decide how to resolve them. If the publisher wants an auction, they can run one — potentially using CloudX's TEE infrastructure for verifiable fairness. But the auction sits on top of matching, not in place of it. + +## What a Protocol-Level Solution Looks Like + +The execution gap requires a protocol that: + +1. **Works with pre-negotiated packages.** The market already happened at planning time. The real-time layer activates deals, it does not negotiate them. + +2. **Separates context from identity.** The buyer needs content signals to decide which packages match. The buyer needs user signals to evaluate eligibility. These two needs must be served by two independent operations so that buyers cannot correlate them. + +3. **Supports catalog and creative refinement.** Activating a package is not always a binary on/off. The buyer may need to specify which catalog items to feature, which creative variant to serve, or which promotions are active. + +4. **Works across surfaces.** The same protocol must work for web pages with ad servers, AI assistants without ad servers, mobile apps with mediation layers, retailers with recommendation engines, and broadcasters with pod servers. + +5. **Meets real-time latency requirements.** Sub-50ms end-to-end, because execution happens on every page load, every conversation turn, every app screen. + +This is what TMP provides: two lightweight operations — [Context Match and Identity Match](/dist/docs/3.0.13/trusted-match/context-and-identity) — that activate pre-negotiated packages across any surface, with structural privacy guarantees and sub-50ms latency. diff --git a/dist/docs/3.0.13/trusted-match/identity-match-implementation.mdx b/dist/docs/3.0.13/trusted-match/identity-match-implementation.mdx new file mode 100644 index 0000000000..1531d36d02 --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/identity-match-implementation.mdx @@ -0,0 +1,116 @@ +--- +title: Identity Match Frequency-Cap Data Flow +sidebarTitle: Frequency-Cap Data Flow +description: "Boundary contract between the impression tracker and the Identity Match service for frequency capping — the data flow only. Internal counting, policy evaluation, and storage layout are buyer-internal concerns." +"og:title": "AdCP TMP Identity Match Frequency-Cap Data Flow" +--- + +# Identity Match Frequency-Cap Data Flow + +This page describes how frequency-cap state reaches the Identity Match service and how Identity Match consumes it at eligibility time. It defines **the data flow only** — what crosses the boundary between the impression tracker and the Identity Match service. Internal mechanics (how the impression tracker counts impressions, where policies live, what storage layout the Identity Match service uses, how identities are deduplicated upstream) are buyer-internal concerns and are out of scope here. + +The wire spec lives in the [TMP specification](/dist/docs/3.0.13/trusted-match/specification); the conformance invariants the Identity Match service must satisfy are also normative there. The reference implementation of the Identity Match cap-state store ships in [`adcp-go/targeting/fcap`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting/fcap). + +## Roles + +| Component | Responsibility | +|---|---| +| **Identity Match service** | At query time, returns `eligible_package_ids` — the subset of requested packages the user is not currently capped on (and that pass other eligibility checks). It does not count impressions and does not own fcap policies. | +| **Impression tracker** | Receives pixel fires, decodes TMPX, applies the buyer's fcap policies (counting, windowing, multi-identity dedup, whatever the buyer's policy logic does), and signals "cap fired" to the Identity Match cap-state store on the impression that exhausts a cap. | +| **Identity Match cap-state store** | Records `(user_identity, package) → cap-until` entries with TTL. Queried by the Identity Match service at eligibility time. Written by the impression tracker (or a downstream service in its pipeline). | + +The split is deliberate: counting impressions, evaluating windows, and deciding when a cap fires are buyer-internal policy concerns that vary across buyers and across campaigns. The Identity Match service stays narrow — it answers "is this user currently capped on this package?" and nothing more. New cap dimensions (advertiser, campaign, creative — see [extensions](#future-extensions)) plug into the same boundary contract without changing the service. + +## End-to-end flow + +``` +1. Identity Match query + publisher → router → Identity Match service + Identity Match looks up cap state for each (identity, package) pair + returns eligible_package_ids + tmpx (HPKE-encrypted resolved identities) + +2. Ad serves; creative tracking URL fires pixel with {TMPX} + publisher's player/page → impression tracker + +3. Impression tracker decodes TMPX + → resolved identities + signed package context (seller_agent_url, package_id) + +4. Impression tracker applies the buyer's fcap policies + → counts this exposure against whatever dimensions the buyer caps on + (package, campaign, advertiser, creative, line item, …) for each + resolved identity, using whatever policy logic and storage the buyer + runs internally + +5. If this impression exhausts a cap (i.e., it is the last allowed exposure + under one of the buyer's policies), the impression tracker (or a + downstream service in its pipeline) writes a cap-fire entry to the + Identity Match cap-state store: + (user_identity, package) capped until + +6. Subsequent Identity Match queries for that user see the cap-state entry + and exclude the package from eligible_package_ids until the entry expires +``` + +Steps 1, 2, and 6 cross the wire and are normatively defined in the [TMP specification](/dist/docs/3.0.13/trusted-match/specification). Steps 3 and 5 cross the impression-tracker → cap-state-store boundary and are defined on this page. Step 4 is buyer-internal — the protocol does not constrain it. + +## The cap-fire event + +When a buyer's policy evaluation determines that an impression has exhausted a cap, the impression tracker writes a cap-fire entry to the Identity Match cap-state store. Each entry consists of: + +| Field | Description | +|---|---| +| `user_identity` | The resolved identity token (e.g., `rampid:abc`, `id5:def`, `maid:ghi`) the cap fired on. If a single impression resolved to multiple identities and the policy fired on all of them, the impression tracker writes one entry per identity. | +| `seller_agent_url` | The seller agent the package belongs to. Disambiguates identical `package_id` strings across sellers. | +| `package_id` | The package the cap fired on. | +| `expire_at` | Wall-clock time at which the cap expires. The cap-state store enforces this as a TTL — entries are absent after `expire_at`. | + +A single cap-fire event typically corresponds to one entry; a cap that fires on multiple resolved identities or multiple packages produces one entry per `(identity, package)` pair, all sharing the same `expire_at` if the buyer's policy is the same. + +The cap-state store does not record per-impression counts, policy definitions, or window configurations. Its only job is to answer "is this `(user_identity, package)` currently capped?" The buyer's policy logic — counting, windowing, choosing dimensions to cap on, deciding when to fire — lives entirely in the impression tracker. + +## The eligibility query + +At query time, the Identity Match service receives a list of identities and a list of candidate packages. For each candidate package, it checks the cap-state store for any matching `(identity, package)` entry across the user's identities. If any entry exists, the package is excluded from `eligible_package_ids`. This is a presence check, not a count. + +Cap state is one input to eligibility. The Identity Match service also evaluates audience membership, package active state, audience freshness, and any other inputs the buyer cares about — see the [conformance invariants](/dist/docs/3.0.13/trusted-match/specification#conformance-invariants-for-identitymatch-eligibility). The cap-state portion of that evaluation is the part this page defines. + +## Policy updates and cap-state re-evaluation + +Cap-state entries are written under whatever fcap policy was in force at cap-fire time. When the buyer's fcap policies change — a window shortens or lengthens, a `max_count` rises or falls, a policy is paused or removed, a package is reassigned to a different policy — the existing cap-state entries written under the old policy can become stale. Stale entries either suppress users who should now be eligible (over-suppression) or fail to suppress users who should now be capped (under-suppression). + +When a fcap rule changes, the buyer's policy owner (typically the impression tracker or a service in its pipeline) MUST re-evaluate every cap-state entry the rule applied to and push the appropriate update to the IdentityMatch cap-state store. Two event shapes cover the cases: + +| Event | When to push | Effect on cap-state | +|---|---|---| +| **Delete cap-state** | A user's exposure count under the new policy is below the new `max_count`, or the policy was removed/disabled, or the package was reassigned away from the policy. | Remove the `(user_identity, package)` entry — the user is no longer suppressed on that package. | +| **Extend cap-state** | A user is still over-cap under the new policy, but the new `expire_at` differs from the existing entry — for example, the window was lengthened (push a later `expire_at`) or shortened (push an earlier `expire_at`). | Overwrite the entry with the new `expire_at`. | + +Re-evaluation runs over the buyer's own counting state (where impression history lives), not over the cap-state store — the cap-state store doesn't carry counts. The output is the set of delete-or-extend events to apply. + +The reference store in [`adcp-go/targeting/fcap`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting/fcap) implements extend natively (a second `RecordCap` for the same `(user_identity, field)` overwrites the prior `expire_at` via `HSETEX`). Delete is a future extension — today, the simplest workaround is to extend with an `expire_at` already in the past, which causes the entry to be treated as absent at the next query and to be reaped by the backend's TTL machinery. + +Re-evaluation can be expensive when a policy applies to many users. Buyers typically run it asynchronously: enqueue the policy-change event, sweep the affected user population in batches, push delete/extend events incrementally. The protocol does not constrain the cadence — only the eventual consistency requirement that cap-state must converge to what the current policies imply. + +## Reference implementation + +The cap-state store API in [`adcp-go/targeting/fcap`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting/fcap) is the reference shape. It exposes two operations: + +```go +RecordCap(ctx, userIdentity string, fields []Field, expireAt time.Time) error +IsCapped(ctx, userIdentity string, field Field) (bool, error) +``` + +— plus batch variants for both. `Field` is `{SellerAgentURL, PackageID}`. The reference store is backed by Valkey 9 hashes, hashed by user identity, with one hash field per `(seller_agent_url, package_id)` tuple and a TTL set to `expire_at`. Other backends (Aerospike, DynamoDB, in-memory, anything) are conformant if they satisfy the boundary contract above. + +## Future extensions + +Today the cap-state store is keyed at `(user_identity, seller_agent_url, package_id)`. Future protocol versions may extend the field to additional dimensions — advertiser, campaign, creative, line item — so a buyer can express caps that span multiple packages without writing N entries on every cap-fire. The boundary contract on this page is unchanged by such extensions: the impression tracker writes cap-fire entries; the Identity Match service checks presence at query time. + +## See also + +- [TMP Specification](/dist/docs/3.0.13/trusted-match/specification) — wire spec, TMPX format, conformance invariants +- [Buyer Guide](/dist/docs/3.0.13/trusted-match/buyer-guide) — buyer agent integration, Context Match + Identity Match flows +- [Migration from AXE](/dist/docs/3.0.13/trusted-match/migration-from-axe) — for buyers transitioning from AXE-shaped pipelines, including the OpenRTB User.eids cross-walk +- [Privacy architecture](/dist/docs/3.0.13/trusted-match/privacy-architecture) — what each party learns +- [Router architecture](/dist/docs/3.0.13/trusted-match/router-architecture) — provider registration, fan-out, latency +- [`adcp-go/targeting/fcap`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting/fcap) — reference cap-state store in Go diff --git a/dist/docs/3.0.13/trusted-match/impression-tracker-implementation.mdx b/dist/docs/3.0.13/trusted-match/impression-tracker-implementation.mdx new file mode 100644 index 0000000000..ccd41d958d --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/impression-tracker-implementation.mdx @@ -0,0 +1,302 @@ +--- +title: Impression Tracker Implementation Reference +sidebarTitle: Impression Tracker Reference +description: "Non-normative reference for the buyer-internal impression tracker — multi-identity dedup, fcap_keys label model, and the path from an impression pixel to a cap-fire entry at the Identity Match boundary." +"og:title": "AdCP TMP Impression Tracker Implementation Reference" +--- + +# Impression Tracker Implementation Reference + +This page is **non-normative reference content** for the impression tracker that sits behind the [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation) boundary. The protocol only constrains: + +- The wire spec — see the [TMP specification](/dist/docs/3.0.13/trusted-match/specification). +- The conformance invariants the Identity Match service must satisfy — also normative in the [TMP specification](/dist/docs/3.0.13/trusted-match/specification#conformance-invariants-for-identitymatch-eligibility). +- The cap-fire boundary contract — defined in [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation). + +Everything on this page is buyer-internal: how the impression tracker counts impressions, deduplicates across resolved identities, evaluates windows, and decides when a cap fires. Buyers running a conformant impression tracker may pick any approach that produces correct cap-fire events at the boundary. This page documents one such approach — the one implemented in [`adcp-go/targeting`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting) — so other implementers have a worked reference. + +## The cross-identity dedup problem + +A single impression on a user is often resolved to multiple identities (RampID, ID5, MAID, UID2, publisher-issued tokens, etc.) inside the same TMPX. A naive impression tracker that counts per-identity will count one impression as 2–3 against the user's caps. If the buyer runs an identity graph, the buyer can canonicalize identities before counting; if the buyer is graphless or partially graphed (common — Scope3's hosted Identity Match is graphless), no canonical id exists. + +Counter-based approaches paper over this with a `merge_rule` (MAX / OR / SUM) when reading per-identity counters. None of the merge rules is correct in general. The pathological case is identity-resolution toggling across impressions: some impressions resolve `rampid` only, some resolve both `rampid` and `id5`. A MAX-merged counter under-counts; SUM over-counts; OR can't represent more-than-one. The cap fires at the wrong time either way. + +The reference impl avoids the merge-rule problem entirely with an `impression_id` scheme: one id per impression, written to every resolved identity's log, deduplicated by id at read time. The count is exact regardless of whether identities are canonicalized upstream. + +## impression_id rules + +The impression tracker generates one `impression_id` per impression at TMPX decode time and writes it to every resolved identity's log. At read time, scanning all of a user's identity logs and deduplicating by `impression_id` recovers the distinct-impression count exactly. + +Required properties: + +1. **Globally unique across all sellers, sources, and time.** A buyer agent serves impressions sourced from many sellers. Collisions across sellers would silently merge distinct impressions and under-count the cap. Use UUIDv4 (≥122 bits randomness) or an equivalent collision-resistant generator. +2. **Generated by the buyer's impression tracker at TMPX decode** — not by the seller, the publisher, the router, or the TMPX nonce. The TMPX nonce is per-Identity-Match-evaluation and shared across all impressions in the serve window; seller- or publisher-supplied IDs would collide. +3. **One id per impression, written to ALL of the user's resolved identity logs for that impression.** Generating a different id per identity breaks the dedup contract — the same impression would count once per resolved identity. +4. **Pixel retries are a separate concern.** The same pixel firing twice (network retry, page refresh, etc.) must not mint two `impression_id`s — minting two would let pixel retries double-count against the cap. Either dedupe incoming requests by an idempotency key in the pixel URL or `Idempotency-Key` header, or accept a small over-count from retries as benign for fcap purposes. Cross-identity dedup and per-pixel idempotency are different problems with different mitigations. (Lowercase wording: this page is non-normative; the boundary contract on the [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation) page is what conformance tests cite.) + +## fcap_keys label model + +Caps are tagged with `dimension:value` labels at impression-write time. Packages declare which labels they map to; fcap policies attach a `window` and a `max_impression_count` to each label. + +``` +package 2342: fcap_keys ["campaign:42", "campaign_group:7", "advertiser:13"] +policy "campaign:42": {window: {interval: 10, unit: "minutes"}, max_impression_count: 5} +policy "campaign_group:7": {window: {interval: 1, unit: "days"}, max_impression_count: 50} +policy "advertiser:13": {window: {interval: 1, unit: "days"}, max_impression_count: 20} +``` + +When the impression tracker writes an exposure for an impression on package 2342, the entry's `fcap_keys` is `["campaign:42", "campaign_group:7", "advertiser:13"]`. When evaluating whether a cap has fired, it scans the log for entries matching each label within that policy's window. + +**Window unit is load-bearing**, not just human-readable shorthand. The reference impl uses `unit` as the sliding-window bucket size: `unit: "hours"` evaluates against hourly buckets; `unit: "minutes"` evaluates against minute buckets. Two policies that look duration-equivalent — `{interval: 2, unit: "hours"}` vs `{interval: 120, unit: "minutes"}` — have the **same window length** but **different post-cap re-evaluation cadence**. After a user hits the 2-hour-bucket cap, the next eligibility check that admits new traffic happens at the next-hour bucket boundary; for the 120-minute-bucket policy, it happens at the next-minute bucket boundary. Pick `unit` to match the cadence you want, not the duration you can fit in the smaller number. + +**Charset constraint.** Each segment matches `[a-zA-Z0-9_-]+` so the `:` delimiter is unambiguous. URL-bearing or otherwise colon-bearing values must be hashed or shortened. + +**Multi-tenant operators** typically adopt a tenant prefix (`buyer-acme:campaign:42`) as a deployment convention to prevent key collisions across advertiser orgs on shared state. This is operator policy, not protocol. + +**Why labels, not hierarchy.** Cap dimensions are heterogeneous across customers — some cap at creative, some at line item, some at advertiser-roll-up. A fixed schema either over-prescribes or under-serves. Labels also make cross-seller caps automatic: any policy whose key is shared across sellers (e.g., `buyer-acme:advertiser:13`) enforces across all of them with no extra mode. Cross-cutting policies are explicit — a campaign that needs both per-campaign and per-advertiser caps declares both keys and gets two policy lookups. + +## Reference data model (valkey-backed, log-based) + +The layout below is what [`adcp-go/targeting`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting) uses. Any backend (Aerospike, DynamoDB, in-memory, anything) is fine; the data shape is the reference, not a requirement. + +### Exposure log (per identity) + +``` +type: STRING (binary-encoded []ExposureEntry, lazy-pruned to window) +key: user:exposures:{HashToken(uid_type + ":" + user_token)} +value: [ + { impression_id, fcap_keys[], timestamp }, + ... +] +``` + +`HashToken` is a 16-byte SHA-256 prefix, hex-encoded. Binary entry encoding keeps the log compact ([`exposure_binary.go`](https://github.com/adcontextprotocol/adcp-go/blob/main/targeting/exposure_binary.go)) — a 30-day log for a typical user is a few KB. + +Each entry records: + +- `impression_id` — generated at TMPX decode. Same value across all of this impression's identity logs. +- `fcap_keys[]` — the labels this impression counts toward. +- `timestamp` — unix seconds. + +### Fcap policy (per fcap_key) + +``` +type: STRING (JSON-encoded FcapPolicy) +key: fcap_policy:{fcap_key} +value: { window: {interval, unit}, max_impression_count, active, updated_at } +``` + +Sliding window applied at read by counting log entries that fall in the current and prior buckets that span the window. Bucket size is derived from `window.unit` (`minutes`/`hours`/`days`/`weeks`/`months`); window length is `interval × unit`. The bucket-level filter, not a per-second `>=` filter on entry timestamps, is what production uses — it makes re-evaluation cadence after a cap fires predictable from the policy's `unit`. + +### Package configuration (per package) + +``` +type: STRING (JSON-encoded PackageConfig) +key: package:identity:{package_id} +value: { + fcap_keys: ["campaign:42", "advertiser:13"], + active: true, + updated_at: +} +``` + +Maps package → fcap_keys. The impression tracker reads this to figure out which labels to tag a new exposure with. + +## Write path: pixel → log + +On a TMPX-bearing pixel fire, the impression tracker: + +1. Decodes the TMPX (HPKE decrypt + binary parse) → resolved identities + `(seller_agent_url, package_id)` context. +2. Looks up the package's `fcap_keys`. +3. Generates one `impression_id`. +4. For each resolved identity, appends `{impression_id, fcap_keys, timestamp}` to `user:exposures:{hash(identity)}`. Prunes entries older than the longest active window (default 30 days). + +The read-modify-write per identity is not atomic in the reference impl ([`engine.go:478`](https://github.com/adcontextprotocol/adcp-go/blob/main/targeting/engine.go#L478)) — concurrent writes for the same user can lose an exposure. The reference impl explicitly accepts this; under-counting under contention is benign for fcap purposes. Atomic append via Lua or a `Store.Append` extension is a deferred optimization. + +## Evaluating whether this impression exhausted a cap + +After writing the exposure, the impression tracker decides whether any cap just fired. **A package typically maps to multiple `fcap_keys` (campaign, campaign_group, advertiser, …), each with its own policy. Policies are evaluated independently, and the cap fires when *any one* of them reaches `max_impression_count` within its window.** A user can be capped on a package by the per-campaign policy without ever approaching the per-advertiser policy, or vice versa. + +For each `fcap_key` on the exposure, the impression tracker scans the user's identity logs: + +1. Read `user:exposures:{h}` for every resolved identity. +2. Filter entries to those that fall in the current+prior buckets spanning `policy.window` and where `fcap_key ∈ entry.fcap_keys`. +3. Deduplicate by `impression_id` across all the user's identity logs. +4. Compare the deduped count to `policy.max_impression_count`. + +If any policy's deduped count is `>= max_impression_count`, the cap fired on this impression. The impression tracker then writes a cap-fire entry to the Identity Match cap-state store for every `(user_identity, package_id)` whose package maps to the exhausted `fcap_key`. The expiration is the end of the current bucket of `policy.window` (which is when the oldest in-scope exposure ages out under bucket semantics). + +For a cap on an advertiser-level label (`advertiser:13`) that maps to multiple packages on multiple sellers, the impression tracker emits one cap-fire entry per `(user_identity, seller_agent_url, package_id)` affected — main's [boundary contract](/dist/docs/3.0.13/trusted-match/identity-match-implementation#the-cap-fire-event) is package-scoped, so cross-dimensional caps fan out at write time. + +## SDK primitives + +The SDK ships impression handling as two composable functions, not one bundled call. Production tracking endpoints typically decode at intake and let a downstream worker write the store at its own pace; bundling decode+write into a single function would force synchronous topology and prevent buffering. + +``` +decodeTmpx(raw_tmpx) -> DecodedExposures + Decrypts HPKE ciphertext, parses the published TMPX binary format + (/docs/trusted-match/specification#binary-format), returns the resolved + identity entries in a structured form ready for serialization onto a + topic or for direct write. The persistent per-identity exposure log + is a separate, store-resident structure — see Reference data model above. + +writeExposure(decoded, fcap_keys, store_context) -> { ok, fired_caps } + Appends entries to each resolved identity's exposure log with a fresh + impression_id and the supplied fcap_keys. Prunes entries older than the + longest active window. Returns the set of caps that fired on this + impression — the caller fans these out to the Identity Match cap-state + store. +``` + +Plus the buyer-side management plane: + +``` +upsertPackage(seller_agent_url, package_id, fcap_keys, opts) +upsertFcapPolicy(fcap_key, {window: {interval, unit}, max_impression_count}) +inspectExposures(uid_type, user_token, fcap_key?) // debugging helper +``` + +Plus HPKE encrypt/decrypt as net-new SDK primitives (X25519 KEM, ChaCha20-Poly1305, HKDF-SHA256 per RFC 9180 `mode_base`). Encrypt is needed by the Identity Match service emitting TMPX; decrypt by the impression tracker invoking `decodeTmpx`. + +The same surface ships in `@adcp/client` (TS), `adcp-go`, and `adcp` (Python). + +> **Primitive names are illustrative.** `decodeTmpx`, `writeExposure`, `upsertPackage`, `upsertFcapPolicy`, and `inspectExposures` describe the shape of the SDK surface; canonical signatures land with the corresponding SDK RFCs and may differ in naming or argument order. Treat this section as the impression-tracker decomposition, not as an API contract. + +## Production topology pattern + +A typical Scope3-style deployment: + +``` +publisher pixel fires {TMPX} → tracking endpoint + │ + decodeTmpx (synchronous, at intake) + │ + ▼ + pub/sub topic + │ + frequency_writer worker + │ + writeExposure (asynchronous) + │ + ▼ + valkey (exposure log) + │ + if cap fired → RecordCap to + Identity Match cap-state store +``` + +Decode at intake; emit to pub/sub for buffering; downstream worker writes the exposure log and emits any cap-fire events. Buffering, retries, dedup, observability, and abuse protection live at the queue layer — none of that is the SDK's job. A simpler synchronous pipeline (decode + write in the same handler) is also valid for low-volume deployments. + +## Conformance scenarios + +These walk through impression-tracker behavior end-to-end. They are buyer-internal mechanics; the on-wire observable is whatever cap-fire entries land in the Identity Match cap-state store, which surfaces as eligibility decisions in later `identity_match_request` calls. + +Setup for both scenarios: `package = "pkg-42"` on `seller-a.example`, `fcap_keys: ["campaign:42"]`, `policy campaign:42 = {window: {interval: 1, unit: "days"}, max_impression_count: 5}`. + +### Scenario A — multi-identity dedup + +User has two resolved identities across the impression stream: `rampid:abc` and `id5:def`. Identity resolution toggles — most impressions resolve both, but one resolves rampid only. + +**imp-001, imp-002, imp-003** — TMPX resolves both identities. Each impression writes the same `impression_id` to both logs: + +``` +user:exposures: = [ imp-001, imp-002, imp-003 ] +user:exposures: = [ imp-001, imp-002, imp-003 ] +``` + +**imp-004** — TMPX resolves rampid only (id5 lookup fails). imp-004 is written to rampid's log only: + +``` +user:exposures: = [ imp-001..imp-004 ] +user:exposures: = [ imp-001..imp-003 ] unchanged +``` + +**imp-005** — TMPX resolves both identities again. imp-005 is written to both logs. The impression tracker then evaluates the cap by reading both resolved-identity logs: + +``` +rampid:abc log: { imp-001, imp-002, imp-003, imp-004, imp-005 } = 5 entries +id5:def log: { imp-001, imp-002, imp-003, imp-005 } = 4 entries +``` + +Union the entries across logs, deduplicate by `impression_id`: + +``` +{ imp-001, imp-002, imp-003, imp-004, imp-005 } = 5 distinct impressions +``` + +5 = `max_impression_count` → the cap just exhausted. Since both identities are resolved on imp-005, the impression tracker emits cap-fire entries for both: + +``` +RecordCap(rampid:abc, [{seller-a.example, pkg-42}], expire_at) +RecordCap(id5:def, [{seller-a.example, pkg-42}], expire_at) +``` + +Two things are demonstrated: + +- **Dedup matters.** Naively summing per-identity counts gives `5 + 4 = 9` — way over `max_impression_count`. Dedup by `impression_id` recovers the correct count of 5. +- **Identity-resolution stability isn't required.** imp-004 missed id5's log entirely; dedup at evaluation time still produces the right answer when both identities are next resolved together. + +A counter-based tracker with a MAX merge_rule would see counters `max(rampid=5, id5=4) = 5` here — coincidentally correct at this point, but only because the divergence happened to be a single missed write. A second missed-id5 impression (imp-006-style) would push rampid to 6 while leaving id5 at 5; MAX would still say 5 and over-serve by one. SUM (= 9 here) over-counts in the opposite direction. The log + `impression_id` dedup is correct by construction. + +A consequence to flag for the implementer: if a future query resolves only id5:def, the cap-state lookup hits the id5:def entry written at imp-005 and the user is correctly suppressed. If neither identity gets resolved in a future query, no cap-state lookup happens at all — that's an identity-resolution problem upstream of fcap, not a fcap correctness problem. + +### Scenario B — cross-seller advertiser cap + +Two packages on different sellers, both mapped to the same advertiser-level label: + +``` +package:identity:pkg-A = { fcap_keys: ["advertiser:13"], active: true } // seller-a +package:identity:pkg-B = { fcap_keys: ["advertiser:13"], active: true } // seller-b +fcap_policy:advertiser:13 = { window: {interval: 1, unit: "days"}, max_impression_count: 10 } +``` + +Ten impressions on `pkg-A` from `seller-a`. Each exposure entry's `fcap_keys` includes `advertiser:13`. At the 10th write, the deduped count for `advertiser:13` matches `max_impression_count`. The impression tracker emits cap-fire entries for **every package mapped to `advertiser:13` across all sellers**, for every resolved identity: + +``` +RecordCap(, [ + {seller-a.example, pkg-A}, + {seller-b.example, pkg-B}, +], expire_at) +``` + +A subsequent `identity_match_request` from `seller-b` for `pkg-B` returns `eligible_package_ids: []` because the cap-state entry is present. The advertiser-level cap enforces across sellers because the `fcap_key` is shared. No cross-seller coordination is required at the IdentityMatch service — the buyer agent's impression tracker is the single source of truth, and the cap-state store is the publication channel. + +## Performance reference + +Numbers below are from [`targeting/scale_test.go`](https://github.com/adcontextprotocol/adcp-go/blob/main/targeting/scale_test.go) against the in-memory mock store, single goroutine. They isolate CPU from network. They describe the **impression tracker's** evaluation cost — the cost of scanning logs and deciding whether this impression just fired a cap. The Identity Match service's at-query-time cost is a separate, much smaller cap-state presence check. + +**Per-eval at write time, varying log size, single identity, single fcap_key:** + +| Prior exposures in user's log | Eval latency | +|---|---| +| 0 | 368 ns | +| 100 | 5.3 µs | +| 1,000 | 53 µs | +| 10,000 | 118 µs | + +Linear scan with binary lazy dedup; sub-millisecond at 10K entries. + +**Combined load (multi-identity, multi-package eval), varying all dimensions:** + +| packages mapped via fcap_keys | log entries / id | identities | CPU/eval | +|---|---|---|---| +| 100 | 1,000 | 3 | 1.0 ms | +| 1,000 | 1,000 | 3 | 7.5 ms ← realistic Scope3-shape load | +| 1,000 | 10,000 | 3 | 58 ms ← pathological tail (heavy users) | + +CPU scales in `packages × log_entries × identities`. The pathological tail is addressed by the algorithmic optimization in [adcp-go#103](https://github.com/adcontextprotocol/adcp-go/pull/103) (heuristic-gated prefilter bucket; gated at `numPackages > 50` to avoid regressions on small requests): + +| packages | log entries | identities | Before | After | Speedup | +|----------|------------:|-----------:|----------:|---------:|--------:| +| 1,000 | 100 | 3 | 784 µs | 71 µs | 11.0× | +| 1,000 | 1,000 | 3 | 7,566 µs | 287 µs | 26.4× | +| 1,000 | 10,000 | 3 | 57,861 µs | 1,500 µs | ~38× | + +Production sizing also depends on valkey round-trip latency, tail behavior under load, and the heavy-user impression-distribution shape. Mock-store CPU is the floor, not the production number. + +## See also + +- [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation) — the cap-fire boundary contract this page sits behind +- [TMP Specification](/dist/docs/3.0.13/trusted-match/specification) — wire spec, conformance invariants +- [`adcp-go/targeting`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting) — reference Go implementation of the model on this page +- [`adcp-go/targeting/fcap`](https://github.com/adcontextprotocol/adcp-go/tree/main/targeting/fcap) — reference cap-state store on the other side of the boundary diff --git a/dist/docs/3.0.13/trusted-match/index.mdx b/dist/docs/3.0.13/trusted-match/index.mdx new file mode 100644 index 0000000000..e4898d02cd --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/index.mdx @@ -0,0 +1,245 @@ +--- +title: Trusted Match Protocol +sidebarTitle: Overview +testable: true +"og:image": /images/walkthrough/tmp-01-wasted-budget.png +description: "AdCP's real-time execution layer — follow a cross-publisher frequency capping story from wasted budget to structural privacy, across every surface." +"og:title": "AdCP Trusted Match Protocol" +--- + +A viewer sees the same outdoor gear ad on their TV and phone within minutes — a budget meter drains with diminishing returns + + +**Experimental.** TMP 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 TMP MUST declare `trusted_match.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. + + +Priya is Director of Ad Products at StreamHaus, a CTV publisher. She designed how StreamHaus's inventory looks to buyer agents — product catalogs, creative specs, pricing. + +Sam's Acme Outdoor campaign runs on StreamHaus, OutdoorNet, and PodTrail with a frequency policy: **5 impressions per week, minimum 2 hours between exposures**. Sam chose this because spaced exposure outperforms concentrated repetition — the same ad every commercial break produces fatigue, not engagement. + +The problem: each publisher counts independently. + +## Step 1: The problem — wasted budget + +StreamHaus, OutdoorNet, and PodTrail each count independently. A viewer who watches hiking content on StreamHaus after dinner, then browses OutdoorNet on their phone 30 minutes later, gets the same ad again — well inside the 2-hour recency window Sam set. + +Multiply across a week and the viewer gets 15 impressions instead of 5, concentrated instead of spaced: + +| Publisher | Impressions counted | Actual viewer experience | +|---|---|---| +| StreamHaus | 5 (within cap) | Same ad every session | +| OutdoorNet | 5 (within cap) | Same ad every session | +| PodTrail | 5 (within cap) | Same ad every session | +| **Total** | **15** | **3x over Sam's cap** | + +Every redundant impression is budget that could have reached someone new. Advertising works better with spacing — each exposure after the first few produces diminishing returns. Sam is buying frequency when he should be buying reach. + +This isn't a StreamHaus problem. It's a structural problem. No single publisher can enforce a cross-publisher cap because no single publisher sees the full picture. + +## Step 2: Adding the TMP Router + +Priya at a terminal, deploying a TMP Router — a diagram materializes showing context and identity paths splitting into separate channels + +Priya deploys the Trusted Match Protocol (TMP) Router — the piece that sits between her ad server and buyer agents, with structurally separate paths for context and identity. She configures Sam's buyer agent (Pinnacle) as a TMP provider, alongside other buyers. + +The router sits between StreamHaus's ad server and the buyer agents. When a user loads a page, the router handles the real-time evaluation. Priya didn't write surface-specific code — the same router handles StreamHaus's website, their mobile app, and their CTV app. + + + +Priya registers Sam's buyer agent (Pinnacle) as a TMP provider on the router: + +```json +{ + "providers": [ + { + "name": "Pinnacle (Acme Outdoor)", + "endpoint": "https://pinnacle.acme.example/tmp", + "context_match": true, + "identity_match": true, + "properties": ["01J5A2B3C4-streamhaus-web", "01J5A2B3C5-streamhaus-ios"], + "latency_budget_ms": 50, + "priority": 1 + } + ] +} +``` + +`context_match: true` means the router sends content context to Pinnacle for targeting. `identity_match: true` means it also sends opaque user tokens so Pinnacle can enforce frequency caps and audience eligibility. `properties` scopes which StreamHaus properties this provider serves — Pinnacle evaluates web and iOS, not CTV. `latency_budget_ms` sets a per-provider timeout; if Pinnacle consistently exceeds it, the router deprioritizes it. + +The router fans out to all configured providers in parallel and merges their responses. + + + +## Step 3: Context Match — what's on the page? + +A StreamHaus article about hiking gear with content signals radiating outward — Sam's buyer agent responds with a package offer and creative manifest + +A viewer opens a StreamHaus article about hiking gear. StreamHaus's properties are registered in the [property governance](/dist/docs/3.0.13/governance/property) with stable `property_rid` identifiers, so the buyer knows exactly which property this request came from. StreamHaus sends a **Context Match** request with the article's content signals, placement, and geo. + +Sam's buyer agent evaluates: "This hiking content matches `pkg-outdoor-display`." It responds with an offer that includes a creative manifest — the Trail Pro 3000 banner. + +The key constraint: **no user identity crosses this boundary.** The buyer evaluates content, not people. It doesn't know who is reading the article — only what the article is about. + + + +Request from StreamHaus to Sam's buyer agent: + +```json +{ + "type": "context_match_request", + "request_id": "ctx-8f3a2b", + "property_rid": "01916f3a-9c4e-7000-8000-000000000010", + "property_type": "website", + "placement_id": "article-sidebar", + "artifact_refs": [ + { "type": "url", "value": "https://streamhaus.example/articles/hiking-gear-2026" } + ], + "context_signals": { + "topics": ["596"], + "taxonomy_source": "iab", + "taxonomy_id": 7, + "keywords": ["hiking gear", "outdoor equipment"] + } +} +``` + +The publisher sends both `artifact_refs` (for buyers that crawl content directly) and `context_signals` (pre-classified topics and keywords as a fallback). The buyer agent already knows which packages are active for this placement — it set them up via `create_media_buy`. No package list needs to travel on the wire. + +Response from Sam's buyer agent: + +```json +{ + "type": "context_match_response", + "request_id": "ctx-8f3a2b", + "offers": [ + { + "package_id": "pkg-outdoor-display" + } + ] +} +``` + + + +## Step 4: Identity Match — is this user eligible? + +An opaque user token with package IDs flows to Sam's buyer agent — a timeline shows last exposure 45 minutes ago, recency window 2 hours, verdict: not eligible + +Separately, StreamHaus sends an **Identity Match** request: an opaque user token and ALL of Sam's active package IDs across every publisher. + +Sam's buyer agent checks its exposure history: "This user saw 1 impression 45 minutes ago on OutdoorNet. The 2-hour recency window hasn't elapsed. **Not eligible.**" + +The key constraint: **no page context crosses this boundary.** The buyer checks eligibility, not content fit. It doesn't know what the user is looking at — only whether this user should see more ads right now. + +The recency check crosses publisher boundaries because Sam's buyer agent maintains a shared exposure store. StreamHaus, OutdoorNet, and PodTrail all send Identity Match requests to the same buyer agent — so it knows the user's total exposure across all three. + + + +Request from StreamHaus to Sam's buyer agent: + +```json +{ + "type": "identity_match_request", + "request_id": "id-7c9e1d", + "seller_agent_url": "https://streamhaus.example", + "identities": [ + { "user_token": "opaque-streamhaus-token-abc123", "uid_type": "uid2" }, + { "user_token": "ID5*zP3wK...", "uid_type": "id5" } + ], + "package_ids": [ + "pkg-outdoor-display", + "pkg-outdoor-ctv", + "pkg-outdoor-audio" + ] +} +``` + +Response from Sam's buyer agent: + +```json +{ + "type": "identity_match_response", + "request_id": "id-7c9e1d", + "eligible_package_ids": ["pkg-outdoor-audio"], + "serve_window_sec": 60 +} +``` + +Only eligible packages are listed — `pkg-outdoor-audio` passes the buyer's checks. The `serve_window_sec: 60` tells the router to cache this eligibility for 60 seconds. + +The example sends `package_ids` explicitly, but the publisher MAY omit it — Sam's identity-match service resolves the active package set from `seller_agent_url`. When `package_ids` IS sent, its composition MUST be independent of the current page — either all-active (every Sam package at StreamHaus) or fuzzed (a random sample padded with synthetic IDs that Sam will silently drop). A page-specific subset is forbidden; it would let the buyer correlate package sets across Context Match and Identity Match, breaking the structural separation. + + + +## Step 5: The join — StreamHaus makes the decision + +Two response cards merge at StreamHaus — the Trail Pro ad fades while a different advertiser's ad activates in its place + +StreamHaus joins both responses locally: + +- **Context Match** said: "Activate `pkg-outdoor-display` with this creative manifest." +- **Identity Match** said: "Not eligible — recency window." + +Result: **suppress the ad.** A different advertiser's campaign fills the slot. Sam's budget is preserved for a better moment — one where the viewer hasn't seen the ad recently and the impression will actually matter. + +The buyer never saw user identity and page context together. Privacy isn't a policy that could be violated — it's structural. The two paths never share data, and the publisher (who already has both signals) makes the final decision. + +## Step 6: Three winners + +Three panels: a viewer relaxing with varied ads across devices, Sam's dashboard showing increased unique reach, Priya seeing rising buyer satisfaction metrics + +**The viewer** had a normal evening across three platforms. They saw the Trail Pro ad during hiking content on StreamHaus — relevant, well-timed. When they browsed OutdoorNet 30 minutes later, a different ad appeared. No sense of being followed across the internet. + +**Sam** got spaced exposure instead of concentrated repetition. His 5 weekly impressions land across different contexts and moments, each one more effective than the 6th or 7th impression would have been. And the budget freed by suppression reaches viewers who haven't seen the ad yet — more unique reach for the same spend. + +**Priya** differentiated StreamHaus. Buyers prefer publishers that support TMP because their frequency policies actually work. StreamHaus's inventory is more valuable per impression because buyers know they're not wasting budget on over-exposed viewers. + +| Before TMP | With TMP | +|---|---| +| Each publisher counts independently | Buyer agent tracks exposure across all publishers | +| 15 impressions per viewer per week | 5 impressions per viewer per week, properly spaced | +| Budget buys frequency | Budget buys reach | +| Concentrated repetition, ad fatigue | Spaced exposure, higher effectiveness per impression | +| Publishers compete on volume | Publishers compete on quality and buyer experience | + +## Step 7: Same protocol, every surface + +Five surface icons — web, mobile, CTV, retail media, AI assistant — connected to a single TMP Router hub, all in teal + +The same TMP Router handles StreamHaus's website, their mobile app, their CTV app, and their AI assistant. Sam's buyer agent works across all of them without surface-specific logic. The protocol handles the surface differences. Priya and Sam handle the business. + +## Go deeper + + + + A mediation protocol for AI assistants — how demand finds conversational AI when the context can't be broadcast. + + + Why existing protocols fail at serve time and why TMP takes a matching approach instead of an auction approach. + + + Both operations with concrete examples, including catalog refinement and the publisher-side join. + + + Authoritative message types, field tables, and conformance requirements. + + + Structural separation, temporal decorrelation, and TEE attestation. + + + Controller vs. processor analysis for each TMP participant. + + + Deployment, fan-out, and provider configuration. + + + +### Surface guides + + + + + + + + diff --git a/dist/docs/3.0.13/trusted-match/migration-from-axe.mdx b/dist/docs/3.0.13/trusted-match/migration-from-axe.mdx new file mode 100644 index 0000000000..c44065c211 --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/migration-from-axe.mdx @@ -0,0 +1,105 @@ +--- +title: Migrating from AXE to TMP +sidebarTitle: AXE Migration +description: "How to migrate from AXE segment targeting to TMP's offer and eligibility model — concept mapping, parallel operation, and cutover." +"og:title": "AdCP Migrating from AXE to TMP" +--- + +# Migrating from AXE to TMP + +AXE and TMP solve the same problem — impression-time execution for pre-negotiated packages — with different architectures. AXE sends a full request (user + context + device) and returns opaque segment IDs. TMP splits the request into two structurally separated operations and returns offers and eligibility. + +This page maps AXE concepts to TMP equivalents and describes how to run both in parallel during migration. + +## Concept Mapping + +| AXE | TMP | Notes | +|---|---|---| +| `axei` (include segment) | Context Match offer | Package matched the content | +| `axex` (exclude segment) | Package absent from `eligible_package_ids` | User fails suppression list, audience rule, or buyer-side frequency check | +| `axem` (macro data) | Creative manifest + Offer `macros` + `{TMPX}` | Structured assets move to the creative manifest; dynamic key-values pass through Offer `macros`; per-user exposure tracking uses the `{TMPX}` macro from Identity Match | +| Orchestrator AXE endpoint | TMP Router | Single binary with two isolated code paths | +| Prebid Real-Time Data (RTD) module | TMP Prebid module | Replaces vendor-specific RTD modules with a single module | +| `axe_integrations` URL | `trusted_match` capability | In `get_adcp_capabilities` response | +| OpenRTB-style request | Context Match + Identity Match | Two requests instead of one bundled request | +| Segment key-values on ad server | Targeting key-values from offers | Same GAM integration, different source | + +## What Changes for Each Role + +### Buyer agents + +**Before (AXE):** Upload audience segments to an orchestrator. Reference segment IDs in `axe_include_segment` / `axe_exclude_segment` on media buys. + +**After (TMP):** Expose Context Match and Identity Match endpoints. Evaluate packages against content signals (Context Match) and user eligibility (Identity Match) in real time. Return offers with creative manifests and eligibility decisions. + +**Key difference:** Your agent makes real-time decisions instead of pre-computing segment memberships. You have full control over targeting logic — no intermediary orchestrator. + +### Publishers + +**Before (AXE):** Enable the orchestrator's Prebid RTD module. Accept `axei`/`axex`/`axem` key-values. Create GAM line items targeting those key-values. + +**After (TMP):** Deploy a TMP Router (or use the TMP Prebid module). Accept offers and eligibility from the router. Set GAM targeting key-values from offer signals and pass through Offer `macros` for dynamic creative rendering. GAM line items target `adcp_pkg` instead of `axei`/`axex`. + +**Key difference:** The router replaces the orchestrator's RTD module. GAM line items reference package IDs instead of opaque segment IDs. + +### Orchestrators + +**Before (AXE):** Operate AXE endpoints, manage segment state, distribute Prebid RTD modules. + +**After (TMP):** Orchestrators can operate TMP Routers on behalf of publishers, or transition to a buyer-side role (operating buyer agent TMP endpoints). The orchestrator-as-middleman role is optional in TMP — buyers and publishers can connect directly through the router. + +## Parallel Operation + +During migration, publishers can run AXE and TMP simultaneously: + +1. **Keep existing AXE RTD module** in Prebid alongside the new TMP module +2. **New media buys** use TMP (no `axe_include_segment` / `axe_exclude_segment`) +3. **Existing media buys** continue using AXE segments until they expire +4. **GAM line items** for both: AXE line items target `axei`/`axex`, TMP line items target `adcp_pkg` + +TMP provides real-time per-user exposure tracking via the [`{TMPX}` macro](/dist/docs/3.0.13/trusted-match/specification#tmpx-exposure-tokens). During parallel operation, both AXE and TMP impressions feed into the buyer's exposure store — AXE via the orchestrator's reporting, TMP via the buyer's impression pixel receiving encrypted TMPX tokens. There is no risk of double-counting because the buyer's exposure store deduplicates by user token and package ID regardless of source. + +### Cutover + +When all active media buys use TMP: + +1. Remove the orchestrator's RTD module from Prebid +2. Remove AXE-targeted GAM line items +3. Update `get_adcp_capabilities` to remove `axe_integrations` and keep `trusted_match` + +## Targeting Overlay Migration + +AXE targeting fields in `create_media_buy` map to TMP behavior: + +| AXE field | TMP equivalent | +|---|---| +| `axe_include_segment` | Context Match — buyer evaluates targeting in real time | +| `axe_exclude_segment` | Identity Match — buyer checks suppression and audience rules | + +New media buys should omit AXE fields entirely. The buyer agent's Context Match and Identity Match logic replaces the orchestrator's segment evaluation. + +## What Doesn't Change + +- **`create_media_buy`** — Same task, same schema (minus AXE fields) +- **`get_media_buy_delivery`** — Same delivery reporting +- **`sync_creatives`** — Same creative sync +- **GAM as the ad server** — TMP still sets key-values that GAM evaluates +- **Geographic and other targeting overlays** — These are media buy fields, not execution-layer concerns + +## OpenRTB User.eids cross-walk + +For buyers bridging from OpenRTB-shaped pipelines, the TMP Identity Match `identities[]` shape maps to OpenRTB 2.6 `User.eids[]` as follows: + +| AdCP TMP `identities[].uid_type` | OpenRTB 2.6 `User.eids[].source` | Notes | +|---|---|---| +| `rampid` / `rampid_derived` | `liveramp.com` | `atype: 3` (person-based, per [IAB AdCOM Agent Types](https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/main/AdCOM%20v1.0%20FINAL.md#list_agenttypes)) | +| `id5` | `id5-sync.com` | | +| `uid2` | `uidapi.com` | `atype: 3` | +| `euid` | `euid.eu` | | +| `pairid` | `iabtechlab.com/pair` | | +| `maid` | `adid` (Android) / `idfa` (iOS) | Atypically carried on `Device.ifa` rather than `User.eids` in OpenRTB | +| `hashed_email` | `liveintent.com` or buyer-specific | `atype: 3` | +| `publisher_first_party` | publisher-defined `source` URL | | +| `other` | buyer-defined `source` URL | | + +The TMP `user_token` field corresponds to `User.eids[].uids[].id`. AdCP carries up to 3 identities per Identity Match request (HPKE size budget — see [TMPX size budget](/dist/docs/3.0.13/trusted-match/specification#size-budget)); OpenRTB has no such limit, so a buyer bridging from OpenRTB into TMP must apply a buyer-configured priority order to truncate (typically: deterministic graphs first — UID2, RampID — then probabilistic or publisher-scoped IDs). diff --git a/dist/docs/3.0.13/trusted-match/privacy-architecture.mdx b/dist/docs/3.0.13/trusted-match/privacy-architecture.mdx new file mode 100644 index 0000000000..990f58ef6e --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/privacy-architecture.mdx @@ -0,0 +1,182 @@ +--- +title: Privacy Architecture +description: How TMP separates user identity from page context, what each party can and cannot learn, and how TEE attestation upgrades the guarantee. +"og:title": "AdCP TMP Privacy Architecture" +--- + +# Privacy Architecture + +TMP's privacy model is structural, not policy-based. The protocol separates user identity from page context so that buyers never receive both together. Without TEE, this separation is enforced by code: the context code path never accesses identity data and vice versa. The code is open-source and auditable. With TEE, attestation proves the expected code is running unmodified, upgrading the guarantee from "auditable" to "independently verifiable." + +This page explains what the separation is, what it prevents, and where the guarantees come from. TMP is the only AdCP domain that enforces privacy structurally — see [Privacy posture across domains](/dist/docs/3.0.13/protocol/architecture#privacy-posture-across-domains) and the cross-protocol [Privacy Considerations](/dist/docs/3.0.13/reference/privacy-considerations) page for how this compares to other domains. + +## The Separation Principle + +The TMP Router is a single binary with two structurally separate code paths: one for context, one for identity. + +| | Context Path | Identity Path | +|---|---|---| +| **Inputs** | Page URL, content signals, topic IDs | Opaque user token, package IDs | +| **Never receives** | Any user identity | Any page context | +| **Returns** | Activated packages, enrichment signals | Eligible package IDs + TTL (seconds) | + +The context code path has no access to identity data. The identity code path has no access to context data. The two paths share no state: no shared memory, no shared database, no communication channel, no shared logs or telemetry. + +### Without TEE + +Separation is enforced by the code itself. The context path cannot read identity data because it is not passed to it, not stored in any location the context path can reach, and not referenced in any data structure the context path processes. The same applies in reverse for the identity path. + +This is verifiable by reading the source code. The router is open-source. Anyone can audit it to confirm that the two code paths are isolated. But this is a trust-and-audit model: you are trusting that the deployed binary matches the published source, and that no modification has been introduced at runtime. + +### With TEE + +TEE (Trusted Execution Environment) attestation removes the need to trust the operator. An attestation document is a cryptographically signed statement of exactly what code is running inside the enclave. A publisher or auditor can: + +1. Obtain the attestation document from the router. +2. Verify the signature against the TEE vendor's root certificate authority. +3. Confirm that the running code matches the published, audited source. + +This makes the separation independently verifiable. No one needs to trust the router operator's claim that they deployed the right binary; the hardware proves it. + +TEE is an upgrade path, not a prerequisite. The protocol works without it. Publishers who need independent verification can require TEE-attested routers; those who are comfortable with code audit and operational trust do not need to. + +## What Each Party Learns + +### What the buyer agent learns + +**From Context Match requests**, a buyer agent learns: + +- Which publisher placements and content artifacts exist +- Content signals via `context_signals` (topics, sentiment, keywords, language, brand safety tier, summary, embedding) — pre-computed by the publisher +- Geographic context (country, region, metro) — publisher-controlled granularity + +**From Identity Match requests**, a buyer agent learns: + +- Which user tokens exist (opaque, publisher-scoped) +- Whether each user is eligible for each of the buyer's active packages +- **Cross-identity equivalence within the publisher's view**: when an IMR carries multiple `identities` entries, the buyer learns that those tokens resolve to the same user from this publisher's perspective. This is an intentional match-rate optimization, but it is also identity-graph enrichment the buyer did not previously have. Publishers who want to avoid disclosing cross-graph joins can send a single `(user_token, uid_type)` per IMR at the cost of match rate. +- **Sensitive token exposure**: `hashed_email` and similar strongly-reidentifying tokens carry higher re-identification risk than opaque provider IDs. Publishers SHOULD treat inclusion as a deployment decision, not a default. When the buyer agent runs in a TEE-attested deployment, the risk surface shrinks to what the buyer's model can infer from the match outcome rather than what a logged payload can reveal post-hoc — TEE does not eliminate the disclosure but it closes the offline-retention vector. Non-TEE deployments should weigh `hashed_email` inclusion against legal/consent posture. + +The buyer computes eligibility internally from frequency caps, audience membership, purchase history, or whatever signals they have. They return a list of eligible package IDs and a TTL. The publisher does not learn why a user is or is not eligible. + +**What the buyer cannot do:** + +- Associate a user token with a page URL or content signal +- Determine that a specific user visited a specific page +- Build a cross-page browsing profile for any user + +These limitations hold because the buyer never receives identity and context in the same request, and the decorrelation mechanisms described below prevent joining them after the fact. + +### What the router operator learns + +The context code path sees content signals and package lists. It fans these out to buyer agents and merges responses. It never processes user tokens. + +The identity code path sees user tokens and package IDs. It fans these out to buyer agents and merges responses. It never processes content signals. + +Without TEE, the operator could theoretically modify the binary to bridge the two paths. The code is open-source and auditable, but you are trusting the operator to run it unmodified. With TEE, attestation proves the binary matches the published source, removing that trust requirement. + +### Router trust boundary for identity filtering + +The router is in the trusted boundary for Identity Match forwarding. When the router filters the `identities` array per provider (sending only tokens the provider's declared `uid_types` include), providers cannot independently verify what the publisher originally sent — they see only the filtered subset signed by the router. This is a deliberate trust placement: the router already performs structural separation of the two code paths, and filtering identity tokens is a simpler guarantee than separating code paths. + +The router MUST NOT add, substitute, or transform identity tokens. The forwarded `identities` MUST be a subset of the publisher-origin array. This invariant is provable from code audit (without TEE) and cryptographically verifiable from attestation measurements (with TEE). Operators running non-TEE deployments accept code-audit as the basis for this invariant; operators running TEE-attested deployments get independent verification. + +Buyers close a residual risk at the protocol layer: when multiple identity types are present, buyers SHOULD prefer opaque provider IDs over `hashed_email` and other strongly re-identifying tokens, so that even a router that strips everything except `hashed_email` does not gain leverage (see [Responding to Identity Match](/dist/docs/3.0.13/trusted-match/buyer-guide#responding-to-identity-match)). + +### What the publisher retains + +The publisher has both context and identity. They are the first party: the user is on their page, using their app, in their conversation. TMP does not change the publisher's data posture. It prevents buyers and intermediaries from obtaining the same combined view. + +The publisher performs the join locally after both responses arrive. They can apply consent logic, frequency management, and relevance ranking on their own infrastructure. + +### TMPX exposure tokens and structural separation + +The Identity Match response can include a `tmpx` field carrying a TMPX token — an HPKE-encrypted blob containing the user's resolved identity tokens. This token flows through creative tracking URLs to the buyer's impression pixel for per-user exposure tracking. + +This data flow bridges the identity and context paths: the TMPX token is produced by Identity Match and consumed via creative tracking URLs that originate from Context Match offers. However, the bridge happens **publisher-side** — the publisher joins the two responses locally and substitutes the TMPX value into tracking URLs during ad serving. The buyer's read replica produces the encrypted token. The buyer's impression pixel receives it. The publisher sees only an opaque blob and MUST NOT parse, log, or make decisions based on its value. + +The TMPX token does not violate structural separation because: + +- The router never sees the decrypted contents — it passes the opaque `tmpx` field through. +- The publisher substitutes the value into tracking URLs without interpreting it. +- Only the buyer's cluster master can decrypt the token (HPKE `mode_base` — encrypted with the master's public key, only the master's private key can decrypt). +- The `country` routing directive on the Identity Match request is stripped by the router before forwarding — the buyer agent never sees which country the user is in. + +## Package Set Decorrelation + +If the context path sent only the packages relevant to this page, and the identity path sent only those same packages, a buyer could compare the two sets and infer which page the identity request came from. TMP prevents this by requiring structurally different sets: + +- **Context Match** sends no package list. The provider evaluates its synced package set for the placement — stable per placement, the same for every user. Because no packages are sent per request, the publisher cannot accidentally leak identity through package filtering. +- **Identity Match** sends `package_ids` (or omits it entirely, in which case the buyer evaluates against its full registered active set for `seller_agent_url`). When sent, composition MUST be statistically independent of the current placement — either all-active (every active package for this buyer at this publisher) or fuzzed (a random sample optionally padded with synthetic non-existent IDs that the buyer silently drops). The buyer evaluates the user against this set, not against just the page-specific subset. + +The context set is scoped to one placement. The identity set is scoped to one buyer's entire active inventory. The two sets are structurally different, and neither reveals information about the other. + +The publisher performs the intersection locally: which packages were both activated by context match and eligible by identity match. + +## Temporal Decorrelation + +Even with separate code paths and different package sets, if Context Match and Identity Match requests arrive at a buyer at the same instant — or always in the same order — from the same publisher, timing or ordering correlation is possible. TMP addresses both: + +- Publishers SHOULD introduce a random delay between context and identity requests. The recommended range is 100-2000ms, uniformly distributed. +- Publishers SHOULD also randomize the order: each opportunity SHOULD have a roughly equal probability of context match going first or identity match going first. A fixed order leaks the pairing through ordering even when the delay is randomized. +- Publishers MAY batch Identity Match requests across multiple page views, further obscuring which context request each identity request corresponds to. +- Publishers MAY route context and identity requests through different network paths. + +Temporal decorrelation is defense in depth. It is not the primary separation mechanism; structural separation and package set decorrelation are. But it closes the timing side channel. + +## TEE Attestation Details + +TMP's reference architecture targets AWS Nitro Enclaves, though the protocol is TEE-agnostic. Any TEE that produces verifiable attestation documents is compatible. + +### What attestation proves + +- The router binary running inside the enclave matches the published, audited source code. +- The code paths for context and identity are structurally separate, with no shared state. +- The binary has not been modified by the operator, the hosting provider, or any runtime process. + +### What attestation does not prove + +- That buyer agents handle the data they receive responsibly. TMP limits what buyers receive; it does not control what they do with it. +- That the publisher's join logic is correct. The publisher is the first party and is not constrained by TMP's separation model. +- That the code is free of bugs. Attestation proves the code matches the published source. Whether that source is correct is a separate question, addressed by open-source audit. + +### Attestation measurements + +Each attestation document includes cryptographic hashes of the running environment: + +| Measurement | What it covers | +|---|---| +| **Image hash** | Hash of the enclave image. Confirms the binary matches the expected build. | +| **Kernel hash** | Hash of the operating environment. | +| **Application hash** | Hash of the application-level code. | +| **Role hash** | Confirms the enclave's permissions match expectations (e.g., no access to external databases). | + +A publisher or auditor can verify these measurements against the published build artifacts. This verification can be automated and performed continuously. + +## Comparison to OpenRTB + +| Signal | OpenRTB | TMP | +|---|---|---| +| User ID + page URL | Same bid request | Separate code paths, never combined | +| Device fingerprint | Included in bid request | Never sent | +| IP address | Included in bid request | Never sent | +| Raw cookies | Included in bid request | Never sent | +| GPS coordinates | Included in bid request | Never sent | +| Browsing history | Constructible via cookie sync | Not constructible: buyer never sees identity + context together | +| Separation verification | Trust the exchange | Code audit (without TEE) or TEE attestation (with TEE) | + +In OpenRTB, the bid request is a bundle of everything: user identity, device signals, page context, behavioral data. Every participant in the auction receives the full bundle. Privacy depends on contractual promises not to misuse the data. + +TMP splits the bundle at the protocol level. Buyers receive context or identity, never both. The separation is structural, not contractual. + +## Regulatory Posture + +TMP's structural separation aligns with the data minimization principle required by GDPR, CCPA, ePrivacy, and similar regulations: + +- Buyer agents never receive user identity paired with content context. Minimization is enforced by the protocol, not by policy. +- The publisher, as the first party with a direct user relationship, controls the join. They can apply consent logic before combining the datasets. +- With TEE attestation, the separation is independently verifiable, providing auditable evidence for regulators. + +This is an architectural observation, not legal advice. Publishers and buyer agents should consult their own legal counsel regarding regulatory compliance. + +For a detailed analysis of how TMP's architecture maps to GDPR controller and processor roles, see [Data Protection Roles](/dist/docs/3.0.13/trusted-match/data-protection-roles). For cross-protocol privacy guidance, see [Privacy Considerations](/dist/docs/3.0.13/reference/privacy-considerations). diff --git a/dist/docs/3.0.13/trusted-match/router-architecture.mdx b/dist/docs/3.0.13/trusted-match/router-architecture.mdx new file mode 100644 index 0000000000..a1c1235076 --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/router-architecture.mdx @@ -0,0 +1,294 @@ +--- +title: The TMP Router +description: Architecture and operation of the TMP Router, including single-binary design, integration paths, and fan-out. +"og:title": "AdCP TMP Router" +--- + +# The TMP Router + +The TMP Router is infrastructure that sits between publishers and buyer agents. It handles request fan-out, response merging, and privacy enforcement. It does not make decisions — it routes requests and aggregates responses. The publisher configures which providers the router calls. + +## What the Router Does + +1. **Fans out requests**: Sends Context Match requests to all configured providers with `context_match` capability. Sends Identity Match requests to all configured providers with `identity_match` capability. +2. **Merges responses**: Combines offers, enrichment signals, and eligibility results from multiple providers into unified responses. +3. **Enforces separation**: Context and identity code paths are structurally separate — the context path never accesses identity data and vice versa. +4. **Manages latency**: Applies adaptive timeouts and deprioritizes providers that consistently exceed the latency budget. + +## Single Binary, Separate Code Paths + +The router is a single Go binary with two structurally separate code paths: one for context match, one for identity match. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ TMP Router │ +│ │ +│ ┌───────────────────────────┐ ┌───────────────────────────┐│ +│ │ Context Match Path │ │ Identity Match Path ││ +│ │ │ │ ││ +│ │ Inputs: │ │ Inputs: ││ +│ │ • Artifact IDs / artifact │ │ • Opaque user token ││ +│ │ • Context signals │ │ • ALL active package IDs ││ +│ │ • Geo, URL hash │ │ ││ +│ │ • Available packages │ │ ││ +│ │ │ │ Outputs: ││ +│ │ Outputs: │ │ • Eligible package IDs ││ +│ │ • Offers │ │ • TTL (seconds) ││ +│ │ • Enrichment signals │ │ ││ +│ │ │ │ ││ +│ │ Never touches: │ │ Never touches: ││ +│ │ • User tokens │ │ • URLs ││ +│ │ • Any identity data │ │ • Content signals ││ +│ └───────────────────────────┘ └───────────────────────────┘│ +│ │ +│ No shared state between code paths. │ +│ One binary, one audit surface, one Docker image. │ +└──────────────────────────────────────────────────────────────┘ +``` + +The separation is in the code and auditable. The context path cannot read identity data because it is not passed to it, not stored in any reachable location, and not referenced in any data structure the context path processes. The same applies in reverse for the identity path. The router is open-source — anyone can verify this by reading the source. + +TEE attestation is an upgrade path. Without TEE, you trust that the operator deployed the published binary. With TEE, attestation proves the deployed binary matches the audited source, removing that trust requirement. + +## Provider Registration + +Publishers configure which providers the router calls. This is an operational relationship — the publisher trusts the provider to participate in their ad decisioning. Provider registrations follow the `provider-registration` schema (`/schemas/tmp/provider-registration.json`). + +### Discovery models + +Provider registration typically comes from the **page configuration** — the publisher declares providers in their Prebid module config or surface-specific setup. This is the standard path and works well for publishers with a stable set of providers. + +**Static configuration** (Prebid config, YAML file, infrastructure-as-code): +- Publisher declares providers at deploy time +- Router reads config at startup and on config reload +- Changes require a config update and reload/redeploy +- Appropriate for most deployments — provider lists change infrequently + +**Dynamic registration** (API-driven, database-backed): +- Publisher manages providers through an admin interface +- Router polls a discovery endpoint or watches for configuration changes +- Changes take effect within one refresh cycle (recommended: 30 seconds) +- Appropriate for publishers managing many providers or needing runtime updates without redeploys +- Dynamic registration endpoints MUST validate that provider `endpoint` URLs are external HTTPS addresses. Implementations MUST reject private (RFC 1918), link-local (169.254.x.x), and cloud metadata IP ranges to prevent SSRF through provider registration. See [Provider registration security](/dist/docs/3.0.13/trusted-match/specification#provider-registration-security) in the specification for the full normative requirements — endpoint URL validation (with DNS re-resolution), dynamic registration endpoint authentication, router-to-provider auth minimum bar, and `/health` endpoint guidance. + +Both models use the same schema. The router does not distinguish between providers loaded from a YAML file and providers loaded from an API — the registration fields are identical. + +### Registration fields + +| Setting | Type | Required | Description | +|---|---|---|---| +| `provider_id` | string | Yes | Stable identifier for this provider. Used in logs, metrics, and cache keys. | +| `endpoint` | URL | Yes | Provider's base URL. The router appends `/context` or `/identity` when dispatching. | +| `context_match` | bool | No | Provider handles Context Match requests. At least one of `context_match` or `identity_match` must be true. | +| `identity_match` | bool | No | Provider handles Identity Match requests. At least one of `context_match` or `identity_match` must be true. | +| `countries` | List\ | Conditional | ISO 3166-1 alpha-2 country codes. MUST be present when `identity_match` is true. | +| `uid_types` | List\ | Conditional | Identity types this provider resolves. MUST be present when `identity_match` is true. | +| `timeout_ms` | integer | No | Per-provider timeout. Must be ≤ the router's `latency_budget_ms`. Default: 50. | +| `priority` | integer | No | Merge conflict resolution order (lower = higher priority). Default: 0. | +| `status` | enum | No | `active`, `inactive`, or `draining`. Default: `active`. | +| `properties` | List\ | No | Property RIDs this provider serves. When absent, the provider serves all properties. | + +At least one of `context_match` or `identity_match` must be true — a provider that handles neither operation is invalid. When `identity_match` is true, `countries` and `uid_types` are **required** — the router cannot route Identity Match requests without them. The router MUST reject invalid provider registrations and SHOULD log a warning identifying the misconfigured provider. + +### Provider lifecycle + +Providers have three lifecycle states: + +- **Active**: Provider receives requests normally. +- **Draining**: Provider stops receiving new requests. In-flight requests complete normally. Use when taking a provider offline for maintenance. +- **Inactive**: Provider is skipped entirely. Use to disable a provider without removing its configuration. + +### Provider health + +Providers SHOULD expose `GET /health` at their base URL. The router uses this for: + +- **Pre-flight checks**: On startup or config reload, verify each provider is reachable before including it in fan-out. +- **Periodic monitoring**: Check provider health on a configurable interval (recommended: 30 seconds). Providers that fail consecutive health checks MAY be temporarily excluded from fan-out and automatically re-included when health recovers. + +Health checks are not in the request hot path — they run on a background interval. The router's `/healthz` endpoint reflects overall router health, not individual provider status. + +Providers MAY support any combination of `context_match` and `identity_match`. A context-only provider handles enrichment or contextual targeting. An identity-only provider handles frequency capping — the publisher evaluates context locally from the media buy's targeting rules and calls the buyer only for identity checks. + +All communication uses JSON over HTTP/2. TMP messages are small (200-600 bytes) — at these sizes, serialization format is less than 1% of total latency. + +## Integration + +### Prebid integration + +Publishers with Prebid Server or Prebid.js add a TMP module that replaces vendor-specific RTD modules. The TMP module sends Context Match and Identity Match requests to the router and returns the merged response as targeting signals and package activation data. The publisher's ad server (GAM, etc.) receives targeting key-values and activates the corresponding line items. + +### Non-Prebid surfaces + +For AI assistants, mobile apps, CTV, and retail media, the router provides a direct HTTP/2 API. Any platform that can make HTTP/2 POST requests can integrate. The request and response schemas are the same regardless of surface. + +### SSP and DSP integration + +SSPs and DSPs integrate as TMP providers — they expose an endpoint that the router calls during fan-out. This is the same pattern as existing RTD integrations. + +### Identity tokens + +Identity tokens come from existing providers (ID5, LiveRamp, UID2, etc.) that are already present on the page or in the app. TMP does not specify token lifecycle — it consumes tokens that the publisher's identity stack already produces. + +## Fan-Out and Response Merging + +### Context Match fan-out + +When the publisher sends a Context Match request: + +1. The router identifies all providers configured for the request's `property_rid` with `context_match` capability. +2. It sends the request to all matching providers in parallel over HTTP/2. +3. It waits for responses up to the latency budget (default: 50ms). +4. It merges responses: + - **Offers** are collected from all providers. If two providers return offers for the same `package_id` (uncommon — packages are typically provider-specific), the router keeps the first response received. Duplicate `package_id` across providers is a configuration error; the router SHOULD log a warning. + - **Enrichment signals** are concatenated. Segments from all providers are combined into a single list. Targeting key-values from different providers are namespaced to prevent collisions. +5. It returns the merged response to the publisher. + +### Identity Match fan-out + +The router filters Identity Match providers by country and identity type: + +1. The router reads the `country` field from the request (a routing directive, not an identity signal). +2. It selects providers whose `countries` list includes that country code. +3. It further filters to providers whose `uid_types` list overlaps with any `uid_type` in the request's `identities` array. +4. For each selected provider, it **filters the `identities` array** to the intersection of the request's identities and that provider's declared `uid_types`. Providers MUST NOT receive identity tokens for types they did not declare — this enforces minimum-necessary-data as a structural privacy property, not an operational one. The router MUST NOT add, substitute, or transform identity tokens; the forwarded set MUST be a subset of the publisher-origin `identities` array. +5. If the intersection is empty, the router MUST skip that provider entirely. An empty `identities` array is not a valid IMR payload (schema enforces `minItems: 1`), and emitting skip-vs-forward as distinguishable telemetry would leak which identity types each user had available. +6. It **strips the `country` field** before forwarding the request to the buyer agent. +7. Because the per-provider payload differs from the inbound request, the router **re-signs** each per-provider forward using the canonical `identities_hash` of the filtered set. Providers verify signatures against the router's public key. +8. It fans out to all matching providers in parallel, merges eligibility results, and returns a unified response. + +Duplicate `package_id` across providers is a configuration error — packages come from media buys and are provider-specific. If it occurs, the router applies conservative merging: the package is only eligible if it appears in `eligible_package_ids` from both providers. The router uses the minimum `serve_window_sec` across providers and SHOULD log a warning. + +### Timeout handling + +The router manages two distinct timeout values: + +- **Overall latency budget** (`latency_budget_ms`): The total time the router has to fan out, collect responses, and merge. Default: 50ms. This is the end-to-end budget the publisher allocates to TMP within their ad serving pipeline. +- **Per-provider timeout** (`timeout_ms` on the provider registration): The maximum time the router waits for a single provider. Must be ≤ the overall latency budget. Default: 50ms (equal to the budget for single-provider setups). + +When multiple providers are configured, the per-provider timeout is the effective cap for each individual provider, and the overall budget is the cap for the entire fan-out. The router enforces the tighter of the two for each provider. For example: with a 50ms overall budget and two providers each set to 40ms, both providers are called in parallel and the router waits at most 50ms total — if provider A responds in 45ms, provider B has already timed out at 40ms. + +- **Single provider timeout**: Skip that provider, log its latency percentile, proceed with responses from remaining providers. The skipped provider's packages are treated as "not activated" for this request. +- **All providers timeout**: Return an empty response — no offers for Context Match, no eligibility for Identity Match. The publisher falls back to existing demand sources (Prebid open auction, direct-sold, etc.). +- **Adaptive timeout**: The router tracks per-provider latency percentiles (p50, p95, p99) and adjusts allocation over time. Consistently slow providers receive smaller timeout allocations or are preemptively skipped. Higher-priority providers (lower `priority` value) receive a larger share of the budget when adaptive allocation is active. This is an operational decision, not a protocol requirement. + +## Latency Budget + +TMP targets sub-50ms end-to-end latency: publisher sends request, router fans out, providers respond, router merges, publisher receives response. + +This is achievable because: + +- **Small messages**: TMP requests are 200-600 bytes of JSON — roughly 10-20x smaller than a typical OpenRTB bid request. Serialization is sub-microsecond. +- **No price computation**: Packages are pre-negotiated. The provider evaluates targeting criteria, not auction dynamics. +- **Parallel fan-out**: All providers are called simultaneously. The total latency is the slowest provider's response time, not the sum. +- **Stateless router**: No database lookups in the hot path. The router's only job is forwarding and merging. +- **Connection reuse**: HTTP/2 multiplexing allows concurrent requests to each provider over a single connection. + +## Comparison to Vendor RTD Modules + +The TMP Router generalizes what vendor-specific RTD modules do today. A single-vendor RTD module evaluates packages against content in real time, but it is locked to one provider, one surface (Prebid), and sends the full OpenRTB BidRequest. + +The TMP Router replaces this with a multi-provider, multi-surface, protocol-standard alternative: + +| | Vendor RTD Module (today) | TMP Router | +|---|---|---| +| Providers | Single vendor | Any provider declaring TMP capabilities | +| Discovery | Publisher configuration | Publisher configuration | +| Surfaces | Web (Prebid Server) | Web, AI, mobile, CTV, retail media | +| Request format | Full OpenRTB BidRequest (~2-10KB JSON) | TMP ContextMatchRequest (~200-600 bytes JSON) | +| Privacy | Data masking before sending | Structural separation (TEE-ready) | +| Identity handling | User ID in bid request | Separate Identity Match operation | + +For existing Prebid Server deployments, the TMP module replaces vendor-specific RTD modules with a generic TMP client. For surfaces without Prebid, the router's HTTP/2 API provides the same functionality. + +## Relationship to TEE Auction Infrastructure + +TEE-based auction infrastructure (encrypted bids, attestation proofs, verifiable winner selection) is complementary to TMP. When a publisher wants competitive selection among activated packages from multiple buyers: + +1. TMP Router collects Context Match responses (which packages each buyer wants to activate). +2. Publisher submits the activated packages (with their pre-negotiated prices) to a TEE auction. +3. The TEE enclave selects the winner and produces an attestation proof. +4. Publisher activates the winning package. + +TMP handles matching. TEE auctions handle competition. Publishers choose whether they need competition at all — many surfaces (editorial AI content, CTV pod composition, retail carousels) are better served by publisher-side relevance ranking than by price-based auctions. + +TEE auction infrastructure (AWS Nitro Enclaves, attestation, key management) is directly applicable when upgrading the TMP Router to TEE-attested operation, making it a natural infrastructure partner for the protocol. + +## Deployment + +The TMP Router is a single Go binary built on [adcp-go](https://github.com/adcontextprotocol/adcp-go). It reads a configuration file listing providers and their capabilities. Each provider exposes two path-based endpoints under its base URL — `POST /context` and `POST /identity` — and the router dispatches by path. + +### Configuration + +```yaml +# tmp-router.yaml +listen: ":8443" +tls: + cert: /etc/tmp/tls.crt + key: /etc/tmp/tls.key + +latency_budget_ms: 50 +adaptive_timeout: true +health_check_interval_sec: 30 + +providers: + # US cluster — UID2, RampID, ID5 + - provider_id: acme-outdoor-us + endpoint: https://us.tmp.acmeoutdoor.example/v1 + context_match: true + identity_match: true + countries: [US] + uid_types: [uid2, rampid, id5] + timeout_ms: 40 + priority: 0 + properties: ["01916f3a-9c4e-7000-8000-000000000010"] + + # EU cluster — EUID, ID5 + - provider_id: acme-outdoor-eu + endpoint: https://eu.tmp.acmeoutdoor.example/v1 + context_match: true + identity_match: true + countries: [DE, FR, IT, ES, NL, BE, AT, PL, SE, DK, FI, IE, PT, GR, CZ, RO, HU, BG, HR, SK, SI, LT, LV, EE, CY, MT, LU, GB] + uid_types: [euid, id5] + timeout_ms: 40 + priority: 0 + properties: ["01916f3a-9c4e-7000-8000-000000000010"] + + # Context-only enrichment provider (no identity match, no country scoping needed) + - provider_id: enrichment-co + endpoint: https://enrichment.example/v1 + context_match: true + identity_match: false + timeout_ms: 30 + priority: 10 +``` + +### Container deployment + +```dockerfile +FROM ghcr.io/adcontextprotocol/tmp-router:latest +COPY tmp-router.yaml /etc/tmp/config.yaml +EXPOSE 8443 +``` + +The router is stateless — no database, no persistent storage. It can be horizontally scaled behind any load balancer. Health checks are available at `/healthz`. + +### Capacity planning + +Each router instance handles approximately 10,000 requests per second on a 2-vCPU container. Memory usage scales linearly with the number of concurrent connections to providers, not with request volume. + +For web publishers, one router instance per point of presence (PoP) is typical. For AI platforms, a centralized deployment with regional failover is sufficient since the router adds < 5ms to end-to-end latency. + +### Monitoring + +The router exposes Prometheus metrics at `/metrics`: + +| Metric | Description | +|---|---| +| `tmp_context_match_duration_ms` | Context Match end-to-end latency histogram | +| `tmp_identity_match_duration_ms` | Identity Match end-to-end latency histogram | +| `tmp_provider_duration_ms` | Per-provider response time histogram | +| `tmp_provider_timeout_total` | Per-provider timeout counter | +| `tmp_provider_error_total` | Per-provider error counter | +| `tmp_offers_total` | Total offers returned across all providers | + +Alert on `tmp_provider_timeout_total` increasing — a provider consistently exceeding its timeout budget degrades match quality for all requests that include it. diff --git a/dist/docs/3.0.13/trusted-match/specification.mdx b/dist/docs/3.0.13/trusted-match/specification.mdx new file mode 100644 index 0000000000..8b488f7d37 --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/specification.mdx @@ -0,0 +1,698 @@ +--- +title: TMP Specification +description: Authoritative message type definitions, field tables, privacy requirements, and conformance levels for the Trusted Match Protocol. +"og:title": "AdCP TMP Specification" +--- + +# Trusted Match Protocol Specification + + +**Experimental.** The Trusted Match Protocol 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 TMP MUST declare `trusted_match.core` in `experimental_features`. See [experimental status](/dist/docs/3.0.13/reference/experimental-status) for the full contract. Fields on this surface are not subject to deprecation cycles until 3.0.0 GA. + + +This is the authoritative reference for the Trusted Match Protocol (TMP). For conceptual introductions, see the [overview](/dist/docs/3.0.13/trusted-match/) and [core concepts](/dist/docs/3.0.13/trusted-match/context-and-identity). + +Specific areas expected to evolve include TMPX exposure tokens, country-partitioned identity, and Offer macros — see the [3.1.0 roadmap](https://github.com/adcontextprotocol/adcp/issues/2201) for planned changes. + +## Definitions + +| Term | Definition | +|---|---| +| **Context Match** | TMP operation that evaluates available packages against content context. Carries no user identity. | +| **Identity Match** | TMP operation that evaluates user eligibility against package criteria. Carries no page context. | +| **TMP Router** | Infrastructure that fans out TMP requests to buyer agents and merges responses. A single binary that handles both context and identity requests, with structurally separate code paths. | +| **Offer** | A buyer's response to a context match request. Ranges from simple activation (package_id only) to rich proposals with brand, price, summary, and creative manifest. | +| **Available package** | A package from an active media buy that is eligible for evaluation on a given placement. Package metadata — including the originating seller agent — is synced at media buy time. See [Package Sync](#package-sync). | +| **Seller agent** | The buyer-side agent that sold the package into a publisher. Identified by the agent URL declared in the publisher's `adagents.json` `authorized_agents[].url`. Every `AvailablePackage` is bound to exactly one seller agent at sync time. | +| **Eligibility** | List of eligible package IDs returned by Identity Match, plus a serve-window throttle. The buyer computes eligibility from frequency caps, audience membership, and other signals; the reasons are opaque to the publisher. | +| **Artifact** | A typed content reference associated with a publisher property (article URL, episode EIDR, show Gracenote ID, music ISRC, product GTIN, conversation turn). Each artifact has a `type` and `value`. Referenced in context match requests. | +| **Temporal decorrelation** | Random delay and random ordering between Context Match and Identity Match requests, preventing timing- and order-based correlation. | + +## Message Types + +All TMP message types include a `type` field that identifies the message for deserialization. Routers and agents use this field to select the correct schema for parsing the JSON body. + +| Message | `type` value | +|---|---| +| Context Match request | `context_match_request` | +| Context Match response | `context_match_response` | +| Identity Match request | `identity_match_request` | +| Identity Match response | `identity_match_response` | +| Error response | `error` | + +### ContextMatchRequest + +Sent by the publisher (via router) to buyer agents. Contains content context. MUST NOT contain user identity. + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | string | Yes | `"context_match_request"`. Message type discriminator for deserialization. | +| `protocol_version` | string | No | TMP protocol version. Default: `1.0`. Allows receivers to handle semantic differences across versions. | +| `request_id` | string | Yes | Unique request identifier for logging. MUST NOT correlate with any Identity Match request_id. | +| `property_rid` | UUID | Yes | Property catalog UUID (v7). Globally unique, stable. | +| `property_id` | string | No | Publisher's human-readable slug. Optional when `property_rid` is present. | +| `property_type` | enum | Yes | One of: `website`, `mobile_app`, `ctv_app`, `desktop_app`, `dooh`, `podcast`, `radio`, `streaming_audio`, `ai_assistant`. See `property-type` enum. | +| `placement_id` | string | Yes | Placement identifier from the publisher's placement registry in `adagents.json`. One placement per request. | +| `artifact` | Artifact | No | Full content artifact adjacent to this ad opportunity. Same schema as content standards evaluation. The publisher sends the full artifact when they want the buyer to evaluate the actual content. Contractual protections govern buyer use. TEE deployment upgrades contractual trust to cryptographic verification. | +| `artifact_refs` | List\ | No | Public content references the buyer can resolve independently. Each has a `type` (one of: `url`, `url_hash`, `eidr`, `gracenote`, `isrc`, `gtin`, `rss_guid`, `isbn`, `custom`) and a `value`. For URL-addressable content, the buyer may have pre-classified these. Use `url_hash` when the publisher prefers not to reveal the URL (contextual clean room). | +| `context_signals` | ContextSignals | No | Pre-computed classifier outputs for the content environment. Use when content is ephemeral (conversation turns, search queries) or to supplement artifact-based matching. Can replace `artifact_refs` entirely. Raw content MUST NOT be included — only classified outputs. The publisher is the classifier boundary. | +| `geo` | Geo | No | Coarse geographic location of the viewer. Publisher controls granularity — country for regulatory compliance, region/metro for campaign targeting and valuation. No postcode or coordinates — coarsened to prevent user identification. | +| `package_ids` | List\ | No | Restrict evaluation to specific packages. When omitted, the provider evaluates all eligible packages for this placement (the common case). Package metadata (formats, catalogs) is synced at media buy time — not sent per request. | + +#### ContextSignals + +Pre-computed classifier outputs for the content environment. MUST NOT contain raw content (conversation text, article body, URLs). Only classified outputs. The publisher is the classifier boundary. + +| Field | Type | Required | Description | +|---|---|---|---| +| `topics` | List\ | No | Content topic identifiers. Use IAB Content Taxonomy 3.0 IDs when `taxonomy_id` is 7 (default), or human-readable strings for custom taxonomies. | +| `taxonomy_source` | enum | No | Organization that defines the topic taxonomy. Default: `iab`. | +| `taxonomy_id` | integer | No | Taxonomy version within the source. For IAB, follows the AdCOM cattax enum: `7` = Content Taxonomy 3.0 (CC-BY-3.0). Default: `7`. | +| `sentiment` | enum | No | Content sentiment: `positive`, `negative`, `neutral`, `mixed`. | +| `keywords` | List\ | No | Content keywords extracted by the publisher's classifier. | +| `language` | string | No | ISO 639-1 language code. | +| `content_policies` | List\ | No | Policy IDs from the [AdCP Policy Registry](/dist/docs/3.0.13/governance/policy-registry) that this content satisfies. Routers populate this from the publisher's property governance configuration or content metadata. Buyers filter on policies they require via `required_policies` on packages. This is a **pre-filtering optimization** — contexts missing required policies are excluded before reaching downstream governance. Definitive enforcement happens at the governance layer via [`check_governance`](/dist/docs/3.0.13/governance/campaign/tasks/check_governance). | +| `summary` | string | No | Natural language summary for relevance judgment. Useful for LLM-native buyers that evaluate semantically. | +| `embedding` | string | No | Content embedding as base64-encoded int8 vector. Captures semantic content beyond topics and keywords. | +| `embedding_model` | string | No | Embedding model identifier (e.g., `nomic-embed-text-v1.5`). Required when `embedding` is present. | +| `embedding_dims` | integer | No | Number of dimensions in the embedding vector. Required when `embedding` is present. | + +Three levels of content disclosure — the publisher chooses based on what the buyer needs and what the publisher is comfortable sharing: + +- **`artifact`** — the full content (article body, transcript, conversation flow, product page). Same schema as content standards artifacts. The buyer evaluates the content directly. Contractual protections govern what the buyer can do with it. TEE deployment adds cryptographic verification on top. +- **`artifact_refs`** — public references (URLs, EIDR IDs, URL hashes) the buyer resolves independently. Use for publicly addressable content the buyer can crawl and classify themselves. +- **`context_signals`** — classified outputs (topics, sentiment, keywords, summary). Use when the publisher wants to describe the content without sharing it or a reference to it. + +`context_signals` is the baseline — every buyer agent MUST handle it. `artifact_refs` and `artifact` are progressive enhancements. Publishers who send `artifact_refs` SHOULD also send `context_signals` as a fallback for buyers who cannot resolve references. + +LLM-based buyer agents SHOULD evaluate `context_signals.summary` and `context_signals.topics` first. These fields provide sufficient signal for most relevance decisions at minimal token cost (~30 tokens). Full content resolution from `artifact_refs` or `artifact` evaluation SHOULD be reserved for high-value packages where precision justifies the cost. Buyers MUST treat `artifact` content and `context_signals.summary` as untrusted publisher-generated input. + +A request can include any combination. A news site sends `artifact_refs` (the URL) and `context_signals` (pre-classified topics). A CTV app sends `artifact_refs` (EIDR IDs) alone. An AI assistant sends `artifact` (the conversation) for buyers that evaluate content directly, plus `context_signals` as a fallback. A publisher who doesn't want to share content or references sends only `context_signals`. + +#### Artifact Ref Type Conventions + +Buyers parse `artifact_refs` strings by pattern. The following conventions are normative: + +| Type | Pattern | Example | +|---|---|---| +| URL | Starts with `https://` | `https://oakwood.example/articles/sustainable-kitchen` | +| URL hash | 44-char base64 (Blake3) | `k7Xp9mQ2vL8nR3wY5tB1aH6jK0pZ4xC9dF2eG7iMqw==` | +| EIDR | Starts with `eidr:` | `eidr:10.5240/XXXX-XXXX-XXXX-XXXX-XXXX-C` | +| Gracenote TMS | Starts with `tms:` | `tms:SH012345670000` | +| RSS + GUID | Starts with `rss:` | `rss:https://feed.example/rss+guid:ep-2026-03-15` | +| GTIN | 8-14 digit numeric | `00012345600012` | + +Buyers SHOULD ignore ref types they do not support rather than failing the request. + +#### Artifact + +A typed content reference. Each artifact identifies a piece of content using a standard or custom identifier scheme. + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | enum | Yes | One of: `url`, `url_hash`, `eidr`, `gracenote`, `isrc`, `gtin`, `rss_guid`, `isbn`, `custom`. | +| `value` | string | Yes | The identifier value. For `url`: canonical content URL (MUST NOT contain user-specific paths or query params; use `url_hash` to avoid revealing URLs). For `url_hash`: base64-encoded Blake3 hash (canonicalization: strip scheme, strip www./m./amp. prefixes, lowercase, strip trailing slash, strip query params and fragments). For `eidr`: EIDR DOI (e.g., `10.5240/xxxx`). For `gracenote`: Gracenote TMS ID (e.g., `SH032541890000`). For `isrc`: ISRC code (e.g., `USRC17607839`). For `gtin`: GTIN (e.g., `00012345678905`). For `rss_guid`: episode GUID from RSS feed. For `isbn`: ISBN (e.g., `978-0-123456-78-9`). For `custom`: publisher-defined string. | + +#### Geo + +Geographic context for the impression opportunity. Publisher controls granularity. + +| Field | Type | Required | Description | +|---|---|---|---| +| `country` | string | No | ISO 3166-1 alpha-2 country code (e.g., `US`, `GB`). | +| `region` | string | No | ISO 3166-2 subdivision code (e.g., `US-CA`, `GB-SCT`). | +| `metro` | Metro | No | Metro area using AdCP's metro classification systems. | + +### ContextMatchResponse + +Returned by the buyer agent. Contains offers for matched packages and optional response-level targeting signals. + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | string | Yes | `"context_match_response"`. Message type discriminator for deserialization. | +| `request_id` | string | Yes | Echo of the request's `request_id`. | +| `offers` | List\ | Yes | Offers from the buyer, one per activated package. Empty list means no packages matched. | +| `signals` | Signals | No | Response-level targeting signals for ad server pass-through. Not per-offer — applies to the response as a whole. In the GAM case, these carry the key-value pairs that trigger line items. | + +#### Offer + +A buyer's response for a single package. + +| Field | Type | Required | Description | +|---|---|---|---| +| `package_id` | string | Yes | Package identifier from the media buy. | +| `seller_agent` | SellerAgentRef | No | Optional echo of the package's seller agent for publisher-side observability. Non-authoritative — the binding on the cached AvailablePackage is source of truth. Routers MAY stamp this field from the cached package→seller map when omitted by the provider. See [Package Sync](#package-sync). | +| `brand` | BrandRef | No | Brand for this offer. Required when the product allows dynamic brands. For single-brand packages, already known from the media buy. | +| `price` | OfferPrice | No | Variable price for this offer. Only present when the product supports variable pricing. | +| `summary` | string | No | Buyer-generated description of the offer for the publisher to judge relevance. E.g., "50% off Goldenfield mayo — recipe integration". | +| `creative_manifest` | CreativeManifest | No | Full creative details, inline. When present, the publisher has everything needed to render. For large creatives (VAST, video), the manifest references external assets via URLs. | +| `macros` | Map\ | No | Key-value pairs for dynamic creative rendering or attribution tracking. In the GAM case, these flow as macro values. Distinct from the Identity Match `tmpx` field which carries an encrypted exposure token for frequency tracking. | + +#### OfferPrice + +| Field | Type | Required | Description | +|---|---|---|---| +| `amount` | number | Yes | Price amount in the specified currency. | +| `currency` | string | No | ISO 4217 currency code. Default: `USD`. | +| `model` | enum | Yes | One of: `cpm`, `cpc`, `cpcv`, `cpa`, `flat`. | + +#### Signals + +Response-level targeting signals for ad server pass-through. + +| Field | Type | Required | Description | +|---|---|---|---| +| `segments` | List\ | No | Audience or contextual segment IDs. | +| `targeting_kvs` | List\ | No | Key-value pairs for ad server targeting. | + +#### KeyValuePair + +| Field | Type | Required | Description | +|---|---|---|---| +| `key` | string | Yes | Targeting key. | +| `value` | string | Yes | Targeting value. | + +### IdentityMatchRequest + +Sent by the publisher (via router) to buyer agents. Contains the seller agent URL, one or more opaque identity tokens, and an optional package ID list. MUST NOT contain page context. + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | string | Yes | `"identity_match_request"`. Message type discriminator for deserialization. | +| `protocol_version` | string | No | TMP protocol version. Default: `1.0`. | +| `request_id` | string | Yes | Unique request identifier. MUST NOT correlate with any Context Match request_id. | +| `seller_agent_url` | string (URI) | Yes | API endpoint URL of the seller agent issuing this request. The buyer's identity-match service uses this to resolve the active package set it has registered for this seller; when `package_ids` is omitted, evaluation occurs against that full set. Compared using the [AdCP URL canonicalization rules](/dist/docs/3.0.13/reference/url-canonicalization), not byte-equality. Consistent with `seller_agent.agent_url` on `AvailablePackage` and `agent_url` in `adagents.json`. | +| `identities` | List\ | Yes | One or more identity tokens for the user. Publishers SHOULD include every token they have available — the buyer resolves on whichever graph matches, maximizing match rate. Each entry is an independent identifier for the same user; buyers MUST NOT treat the combination as a new correlated identity. | +| `consent` | Consent | No | Privacy consent signals. Buyers in regulated jurisdictions MUST NOT process identity tokens without consent information. | +| `package_ids` | List\ | No | When omitted, the buyer evaluates eligibility against the full set of active packages it has registered for `seller_agent_url`. When provided, composition MUST be statistically independent of the current placement. Two acceptable modes: **all-active** (every active package for this buyer at this publisher) or **fuzzed** (a random sample of active packages, optionally padded with synthetic non-existent IDs, drawn from a distribution that does not depend on the current placement). The page-specific subset is forbidden — it would let the buyer correlate with Context Match by comparing package sets. | +| `country` | string | No | ISO 3166-1 alpha-2 country code. Routing directive — the router uses this to select the correct regional provider. The router MUST strip this field before forwarding to the buyer agent. Not an identity signal. | + +Each entry in `identities` is an `{user_token, uid_type}` pair: + +| Field | Type | Required | Description | +|---|---|---|---| +| `user_token` | string | Yes | Opaque token from an identity provider (ID5, LiveRamp, UID2) or publisher-generated. Buyer may map to internal identity graph but cannot reverse to PII. | +| `uid_type` | enum | Yes | Type of user identifier: `uid2`, `rampid`, `id5`, `euid`, `pairid`, `maid`, `hashed_email`, `publisher_first_party`, `other`. Tells the buyer which identity graph to resolve against. See `uid-type` enum. | + +### IdentityMatchResponse + +Returned by the buyer agent. A list of eligible package IDs with a serve-window throttle. + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | string | Yes | `"identity_match_response"`. Message type discriminator for deserialization. | +| `request_id` | string | Yes | Echo of the request's `request_id`. | +| `eligible_package_ids` | List\ | Yes | Package IDs the user is eligible for. Packages not listed are ineligible. | +| `serve_window_sec` | integer | Yes | Per-package single-shot fcap window, in seconds. Range: 1–300. Default: 60. After serving the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again. This is **not** a router response cache TTL — it is a buyer-asserted serve throttle. Multi-impression frequency caps are handled separately by the buyer's impression tracker, which writes cap-fire events to the IdentityMatch cap-state store at the boundary regardless of this window — see [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation). | +| `tmpx` | string | No | HPKE-encrypted exposure token containing resolved user identity tokens. The publisher substitutes this into creative tracking URLs as `{TMPX}`. The buyer's impression pixel receives the token, enabling real-time per-user frequency state updates. Wire format: `kid.base64url_nopad(ciphertext)` (unpadded, no `=` characters). Publishers MUST treat this value as opaque pass-through data. | + +The response includes eligible package IDs, a serve-window throttle, and an optional `tmpx` field. The TMPX token is an HPKE-encrypted exposure token that flows through creative tracking URLs to the buyer's impression pixel, enabling real-time per-user frequency state updates without exposing user identity to the publisher. The buyer computes eligibility from whatever identity signals they have (frequency caps, audience membership, purchase history) and returns only the packages that pass. The publisher does not need to know why a package was excluded — just which packages are eligible. + +The `serve_window_sec` field is a **per-package single-shot fcap**, not a router cache TTL. The buyer is saying: "After you serve the user one impression on each eligible package, re-query me before serving from those packages again." The router MAY still cache the response for an internal deduplication/cost-saving window, but the binding contract on the publisher side is "one impression per eligible package per window." Multi-impression frequency caps (5 per day per campaign, 100 per month per advertiser, etc.) live in the buyer's impression tracker and surface to the IdentityMatch service as cap-fire events at the boundary regardless of `serve_window_sec`. + +The publisher enforces allocation rules (competitive separation, pod composition) using the eligibility list as input. This eliminates the need for pod-specific or batch-specific protocol semantics — the publisher allocates across whatever placements exist during the serve window (a CTV ad pod, a web page with 20 slots, a single pre-roll), honoring the one-impression-per-package contract. + +#### Conformance invariants for IdentityMatch eligibility + +A conformant IdentityMatch service MUST compute `eligible_package_ids` such that, for each `package_id ∈ request.package_ids`, the package is included in `eligible_package_ids` if and only if **all** of the following hold: + +1. **Audience eligibility.** Either the package has no audience requirement, OR there exists at least one audience identifier `a` such that `a` is in the package's required audience set AND `a` is in the audience-membership of at least one identity `i ∈ request.identities` (the union across the user's resolved identities intersects the package's required audiences). +2. **Frequency cap eligibility.** No `(identity, package)` cap-state entry exists for any identity `i ∈ request.identities` against the package. Cap-state entries are written by the buyer's impression tracker when it determines an impression has exhausted a cap and carry an expiration timestamp; an entry is "present" until that timestamp. The protocol does not constrain how the impression tracker counts impressions, evaluates windows, or decides when a cap fires — only the boundary contract (cap-fire entries flow into the cap-state store; the IdentityMatch service checks presence at query time). See [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation) for the boundary contract. +3. **Active state.** Packages or policies marked inactive MUST be treated as if absent. +4. **Audience freshness.** If the buyer's audience pipeline publishes a freshness deadline and the current time is past it, that audience-membership entry MUST NOT contribute to (1). + +The TMPX returned with the response MUST encode the resolved identities so the out-of-band impression tracker can update fcap policy state and signal cap-fire events to the IdentityMatch cap-state store — see § TMPX tokens and [Frequency-Cap Data Flow](/dist/docs/3.0.13/trusted-match/identity-match-implementation). + +Storage backend (valkey, Aerospike, DynamoDB, in-memory, anything) is implementation. Two services with different storage backends that satisfy these invariants for the same inputs MUST return the same eligibility output. + +#### Consent + +Privacy consent signals for the identity match. Publishers MUST include consent information when operating in regulated jurisdictions (EU/EEA, California, etc.). Buyers MUST NOT process user tokens without consent information when required by applicable law. + +| Field | Type | Required | Description | +|---|---|---|---| +| `gdpr` | bool | No | Whether GDPR applies to this request. | +| `tcf_consent` | string | No | IAB TCF v2.2 consent string. Present when `gdpr` is true. | +| `gpp` | string | No | IAB Global Privacy Platform string. | +| `us_privacy` | string | No | US Privacy string (CCPA). Deprecated in favor of GPP but still widely used. | + +### Error Response + +Returned by a provider or router when a request cannot be processed. Distinct from an empty result — an empty `offers` array or empty `eligible_package_ids` list is a valid response meaning no matches, not an error. + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | string | Yes | `"error"`. Message type discriminator for deserialization. | +| `request_id` | string | Yes | Echo of the original request's `request_id`. | +| `code` | enum | Yes | Machine-readable error code: `invalid_request`, `unknown_package`, `seller_not_authorized`, `rate_limited`, `timeout`, `internal_error`, `provider_unavailable`. `seller_not_authorized` is returned at [package sync](#package-sync) time when an AvailablePackage declares a `seller_agent.agent_url` not present in the publisher's adagents.json. | +| `message` | string | No | Human-readable error description for debugging. | + +The router SHOULD exclude providers that return errors from the merged response for that request. The router MAY track error rates per provider and preemptively skip providers with sustained errors. + +## Provider Registration + +TMP providers are registered with the router via publisher configuration. The publisher specifies which providers the router should call, along with each provider's endpoint and supported capabilities. This is an operational relationship — the publisher trusts the provider to run code in their ad decisioning path. + +The standard registration path is **static configuration** — the publisher declares providers in their Prebid module config, router YAML, or equivalent surface-specific configuration. Dynamic registration (API-driven, database-backed) is an equally valid variant for publishers who manage many providers or need runtime updates. Both approaches use the same provider registration schema (`/schemas/tmp/provider-registration.json`). + +| Setting | Type | Required | Description | +|---|---|---|---| +| `provider_id` | string | Yes | Stable identifier for this provider registration. Used in logs, metrics, and cache keys. | +| `endpoint` | URL | Yes | Provider's base URL. The router appends `/context` or `/identity` when dispatching. | +| `context_match` | bool | No | Provider handles Context Match requests. At least one of `context_match` or `identity_match` must be true. | +| `identity_match` | bool | No | Provider handles Identity Match requests. At least one of `context_match` or `identity_match` must be true. | +| `countries` | List\ | Conditional | ISO 3166-1 alpha-2 country codes this provider serves. MUST be present and non-empty when `identity_match` is true. | +| `uid_types` | List\ | Conditional | Identity types this provider can resolve (from `uid-type` enum). The router selects providers whose `uid_types` overlaps with any `uid_type` in the request's `identities` array, and filters the forwarded `identities` to the intersection — providers MUST NOT receive tokens for types they did not declare. MUST be present and non-empty when `identity_match` is true. | +| `properties` | List\ | No | Property RIDs this provider serves. When absent, the provider serves all properties. | +| `timeout_ms` | integer | No | Per-provider timeout in milliseconds. Must be ≤ the router's overall `latency_budget_ms`. Default: 50. | +| `priority` | integer | No | Provider ordering for merge conflict resolution. Lower values = higher priority. Default: 0. | +| `status` | enum | No | Provider lifecycle status: `active`, `inactive`, or `draining`. Default: `active`. | + +At least one of `context_match` or `identity_match` must be true — a provider that handles neither operation is invalid. When `identity_match` is true, `countries` and `uid_types` are **required** — the router cannot perform country-partitioned identity routing without them. The schema enforces both constraints. + +Providers MAY support any combination of `context_match` and `identity_match`. A provider that supports only `context_match` is a pure enrichment or contextual targeting provider. A provider that supports only `identity_match` is a frequency capping provider — the publisher evaluates context locally from the media buy's targeting rules and calls the buyer only for identity checks. + +### Provider lifecycle + +Providers have three lifecycle states: + +- **Active**: Provider receives requests normally. +- **Draining**: Provider stops receiving new requests. In-flight requests complete normally. Use this when taking a provider offline for maintenance — the router finishes current work without starting new fan-outs to this provider. +- **Inactive**: Provider is skipped entirely. Use this to disable a provider without removing its configuration. + +State transitions are immediate in static configuration (reload the config) and take effect within one refresh cycle in dynamic registration. + +### Provider registration security + +**Endpoint URL validation (SSRF).** Both static configuration and dynamic registration MUST validate provider `endpoint` URLs against the canonical [Webhook URL validation (SSRF)](/dist/docs/3.0.13/building/by-layer/L1/security#webhook-url-validation-ssrf) rules — HTTPS only in production, reserved IPv4 and IPv6 ranges rejected (including `::ffff:0:0/96` IPv4-mapped bypasses and the `169.254.169.254` / `fd00:ec2::254` cloud metadata addresses), no redirects. Because a router calls the provider on every request, DNS rebinding is the primary risk: routers MUST either pin the TCP connection to the IP that passed validation or re-validate the socket's post-handshake peer address before sending the request body. Re-resolving DNS without pinning is not sufficient. + +**Dynamic registration authentication.** The dynamic registration API is a privileged surface; unauthenticated registration lets an attacker point publisher traffic at arbitrary HTTPS endpoints. Routers exposing dynamic registration MUST authenticate callers (mTLS or short-lived OAuth 2.0 tokens; static API keys only with IP allow-listing) and SHOULD apply per-agent rate limits on mutations and a cap on total registered providers per publisher to bound registration-storm abuse. + +**Router-to-provider auth.** The existing "deployment-specific (mTLS, API key, etc.)" language in [Request Authentication](#request-authentication) sets the mechanism; the minimum bar is that production providers MUST NOT accept anonymous calls. Static bearer tokens MAY be used only with IP allow-listing. + +**`/health` endpoint.** The `/health` endpoint providers are encouraged to expose for router liveness MAY be unauthenticated, but the response MUST NOT leak internal state. Providers SHOULD return `200` with body `{"status": "ok"}` when ready and `503` when not ready; other status codes are bugs. Providers MUST NOT differentiate status codes or response bodies by internal subsystem (for example, a distinct code when the database is down versus when the identity cache is down is a side-channel that maps external probing onto internal topology). Version strings, build hashes, internal hostnames, and dependency statuses MUST NOT appear in the body. Rate-limit the endpoint (recommended: 1 req/sec per source IP) so it doesn't become a DoS amplifier. + +## Product Integration + +Publishers declare TMP support on their products via the `trusted_match` field. Buyers see this on `get_products` and know what TMP capabilities are available. + +| Field | Type | Required | Description | +|---|---|---|---| +| `context_match` | bool | Yes | Product supports Context Match requests. | +| `identity_match` | bool | No | Product supports Identity Match requests. Default: false. | +| `response_types` | List\ | No | What the publisher can accept back: `activation` (default), `catalog_items`, `creative`, `deal`. | +| `dynamic_brands` | bool | No | Whether the buyer can select a brand at match time. When false (default), brand must be on the media buy. When true, the buyer's offer can include any brand — the publisher applies approval rules at match time. Enables multi-brand agreements. | +| `providers` | List\ | No | TMP providers integrated with this product's inventory. Each entry identifies a provider by `agent_url` (from the registry) and declares what match types it supports. The product-level `context_match` and `identity_match` booleans declare overall support; per-provider booleans declare which provider handles each. Enables buyer discovery. | + +#### ProviderEntry + +| Field | Type | Required | Description | +|---|---|---|---| +| `agent_url` | string (URI) | Yes | Provider's agent URL from the registry. Canonical identifier for this TMP provider. Compared against the router's provider registry using the [AdCP URL canonicalization rules](/dist/docs/3.0.13/reference/url-canonicalization), not byte-equality. | +| `context_match` | bool | No | Whether this provider handles context match for this product. Default: false. | +| `identity_match` | bool | No | Whether this provider handles identity match for this product. Default: false. | +| `countries` | List\ | No | ISO 3166-1 alpha-2 country codes this provider serves. The router filters providers by the request's `country` field. Required when `identity_match` is true. | +| `uid_types` | List\ | No | Identity types this provider can resolve (from `uid-type` enum). The router selects providers whose `uid_types` overlaps with any `uid_type` in the request's `identities` array, and filters the forwarded `identities` to the intersection — providers MUST NOT receive tokens for types they did not declare. Required when `identity_match` is true. | + +## Package Sync + +Package metadata is synced from seller agents to TMP providers at media buy creation time and whenever the media buy materially changes. Providers cache the `AvailablePackage` set per placement and use it at request time — no package metadata flows through `context_match_request` or `identity_match_request`. The sync transport, authentication, and batch-error shape are deployment-specific; this section defines the payload contract and the obligations each participant inherits. + + +`seller_agent` on `AvailablePackage` is required under the experimental `trusted_match.core` surface. Sellers running TMP in existing deployments must update their sync payloads to populate it — see the [experimental feature contract](/dist/docs/3.0.13/reference/experimental-status) for the 3.x-to-3.x evolution policy. + + +### AvailablePackage + +| Field | Type | Required | Description | +|---|---|---|---| +| `package_id` | string | Yes | Unique identifier for the package. | +| `media_buy_id` | string | Yes | Media buy this package belongs to. | +| `seller_agent` | SellerAgentRef | Yes | Seller agent that owns this package. `agent_url` MUST match one of `authorized_agents[].url` in the adagents.json authoritative for every property this package may serve. See [Seller Agent Attribution](#seller-agent-attribution). | +| `format_ids` | List\ | No | Creative format identifiers eligible for this package. Uses the standard `{agent_url, id}` shape. | +| `catalogs` | List\ | No | Buyer catalogs attached to this package, with selectors scoping which items are in play. Referenced by `catalog_id` against separately-synced catalog data. | + +#### SellerAgentRef + +| Field | Type | Required | Description | +|---|---|---|---| +| `agent_url` | string (URI) | Yes | The seller agent's API endpoint URL, exactly as declared in the property publisher's adagents.json `authorized_agents[].url`. HTTPS in production. | +| `id` | string | No | Reserved for a future registry-assigned stable seller identifier. Not used today; included so the reference can absorb an opaque ID layer later without a breaking rename. | + +### Seller Agent Attribution + +TMP providers cache packages from many seller agents against many publishers. The `seller_agent` field makes that provenance explicit on each cached `AvailablePackage` so providers — which have no access to a media-buy store — can attribute offers, apply per-seller observability, and resolve disputes without out-of-band lookups. + +The canonical seller identity in AdCP is the agent URL declared in the property publisher's [adagents.json](https://adcontextprotocol.org/schemas/3.0.13/adagents.json) `authorized_agents[].url` entry. TMP reuses that URL directly rather than introducing a parallel identifier space. The `id` slot on `SellerAgentRef` is reserved for a future registry-assigned opaque identifier and is not used today. + +**Placement rationale.** Context Match and Identity Match are processed by different actors with different package visibility, so seller identity is on the wire for one but not the other: Context Match goes to the provider (which already has the sync-time binding), Identity Match goes to the buyer agent (which needs the seller URL to index its own registered active set). `seller_agent` lives on the cached `AvailablePackage` (sync time) and does not appear on `context_match_request`: + +- Sync time is when a provider first learns about a package; the binding is established once and reused for every subsequent evaluation. +- Putting `seller_agent` on `context_match_request` would either duplicate the sync-time binding (redundant) or open a path for request-time seller filtering on the context path, which re-introduces the identity- and allocation-leakage failure modes [Package set decorrelation](#package-set-decorrelation) exists to prevent. +- Publishers and routers can derive seller identity from `media_buy_id` via their own stores; providers cannot. Keeping attribution on the package serves the actor that actually needs it on the wire. + +`identity_match_request` carries `seller_agent_url` directly because the buyer's identity-match service needs it to resolve the active package set it has registered for that seller — that resolution happens at the buyer agent, not at the provider, and is keyed on seller URL rather than on a per-package binding. This is buyer-side scoping, not provider-side filtering, and so does not interact with the package-set decorrelation guarantee. + +**Offer echo.** `seller_agent` MAY appear on `offer.json` as an echo from the cached package, for publisher-side log pipelines that want one-hop attribution without rejoining to the media-buy store. The echo is non-authoritative — the cached `AvailablePackage` binding is source of truth. Providers and routers MUST overwrite a disagreeing echo with the cached binding before logging, forwarding, or emitting the offer downstream, and MUST NOT use the echo value in any field consumed for billing, reporting, or dispute resolution. A disagreement SHOULD be counted to a `seller_agent_echo_mismatch` metric for anomaly detection. Routers MAY stamp `seller_agent` on merge when providers omit it. + +### Sync-Time Validation + +Providers SHOULD validate `seller_agent.agent_url` against the property publisher's adagents.json at sync time: + +1. For each property the package may serve, fetch `/.well-known/adagents.json` on the property domain. If the file contains an `authoritative_location` pointer, follow it at most one hop to a same-scheme HTTPS URL; do not chain further. Both the initial fetch and the `authoritative_location` hop MUST apply the [Webhook URL validation (SSRF)](/dist/docs/3.0.13/building/by-layer/L1/security#webhook-url-validation-ssrf) rules — HTTPS-only, reserved IPv4 and IPv6 ranges rejected (including `::ffff:0:0/96` IPv4-mapped bypasses and the `169.254.169.254` / `fd00:ec2::254` cloud metadata addresses), no transparent redirects, and the TCP connection pinned to the validated IP. The inbound-fetch rules referenced for [Provider registration security](#provider-registration-security) apply equally to this outbound fetch. +2. Confirm that `seller_agent.agent_url` appears in `authorized_agents[].url` and that any `property_ids`, `collections`, `placement_ids`, `placement_tags`, `countries`, `effective_from`, and `effective_until` constraints on that entry permit the package's scope. The URL comparison uses the [AdCP URL canonicalization rules](/dist/docs/3.0.13/reference/url-canonicalization) — canonicalize both values before matching, never byte-equality. Reject `seller_agent.agent_url` values that do not use the `https://` scheme with `seller_not_authorized`; non-HTTPS seller URLs have no transport-integrity guarantee and cannot be trusted as an authorization key. +3. On mismatch, reject the sync operation for that `AvailablePackage` with an `error` response using `code: seller_not_authorized`. Other packages in the same sync batch are unaffected. The exact wire shape of the sync error is deployment-specific; `code` is the machine-readable reason. + +**Cache and re-validation.** The validation result SHOULD be cached for at most 5 minutes, matching the recommended adagents.json cache window. Providers MUST re-validate on cache expiry and SHOULD surface sustained `seller_not_authorized` rejections to publisher operations — repeated failures usually indicate the publisher's adagents.json and the seller's sync pipeline have diverged. When an authorization's `effective_until` passes or the seller is removed from `authorized_agents`, providers MUST treat previously-cached packages from that seller as `unknown_package` at request time until they are re-synced and re-validated. + +**Fetch failures.** When validation cannot complete (fetch error, timeout, cert failure, malformed file), providers SHOULD reject the sync with `seller_not_authorized` rather than cache an unvalidated binding. Providers that fail-open MUST bound the unvalidated window to the 5-minute cache TTL and re-validate at the next opportunity; a binding that has never successfully validated MUST NOT remain cached past that window. + +**Bypass for pre-attested relationships.** Providers MAY skip the adagents.json check only when they can verify the same `agent_url → authorized_agents[].url` binding through an out-of-band onboarding process (e.g., a mutually authenticated provider-seller enrollment that carries the publisher's attestation), and SHOULD publish their enforcement mode (`enforcing` / `advisory`) in their conformance self-report so publishers can gate onboarding. This escape hatch is permitted for `trusted_match.core` v1 and will be removed in the first non-experimental TMP release, at which point the validation becomes MUST. + +### Participant Responsibilities + +| Actor | Sync time | Request time | +|---|---|---| +| **Seller agent** | Include its own adagents-registered `agent_url` as `seller_agent.agent_url` on every `AvailablePackage` it syncs. MUST be the URL the publisher has attested in `authorized_agents[].url`. | No new behavior. The offer MAY echo `seller_agent`; omitting it is fine — the router can stamp from cache. | +| **Publisher** | Keep `authorized_agents` in adagents.json accurate, including scope constraints (`property_ids`, `placement_ids`, `countries`, effective windows). | No new behavior. | +| **Router** | No new behavior. | MAY stamp `seller_agent` on offers that arrive without it, using its cached package→seller binding. MUST NOT forward `seller_agent` into `context_match_request`. (`identity_match_request` carries `seller_agent_url` directly per its schema; the router MUST forward it unchanged.) | +| **Provider** | Validate `seller_agent.agent_url` against the property's adagents.json per [Sync-Time Validation](#sync-time-validation). Reject unauthorized packages with `seller_not_authorized`. Store the binding with the cached package. | Echo `seller_agent` on offers it produces. The cached binding is immutable for an in-flight decision, but providers MUST treat packages whose authorization has expired (removal from `authorized_agents` or `effective_until` passed) as `unknown_package` on subsequent requests until the package is re-synced. | + +### What This Is Not + +- **Not a request-time filter on Context Match.** Publishers, routers, and providers MUST NOT use `seller_agent` to scope, filter, or route `context_match_request`. `package_ids` is not present on Context Match either; package selection is provider-side based on the cached sync. `identity_match_request` does carry `seller_agent_url`, but only as the key the buyer agent uses to resolve its registered package set — not as a provider/router filter. +- **Not a sellers.json bridge.** IAB sellers.json `seller_id` and TAG-IDs serve a distinct financial-audit identity space. They remain on `adagents.json` `contact` — TMP does not duplicate them. +- **Not a cryptographic attestation.** The binding is publisher-attested via adagents.json over HTTPS. Signed TMP seller claims are a future enhancement and can layer onto `SellerAgentRef` through the reserved `id` slot or an `ext` field without breaking changes. When a future release populates both `agent_url` and `id`, `agent_url` remains authoritative and `id` is advisory — a disagreement between the two MUST NOT be used to upgrade trust above what the URL's adagents.json binding provides. + +## Privacy Requirements + +The following requirements use RFC 2119 keywords (MUST, SHOULD, MAY). + +### Structural separation + +- Context Match requests MUST NOT contain user identity data (user tokens, device IDs, IP addresses, session tokens, or any data that could identify a specific user). +- Identity Match requests MUST NOT contain page context data (URLs, content hashes, topic IDs, content signals, or any data that could identify what the user is viewing). +- The TMP Router MUST process Context Match and Identity Match in structurally separate code paths with no shared state. +- Context Match and Identity Match request IDs MUST NOT be correlated or derivable from each other. + +### Package set decorrelation + +- Context Match MUST NOT be filtered by user identity or audience. The provider evaluates its synced package set for the placement — the same packages for every user. No per-request package list is sent, so the publisher cannot accidentally leak identity through package filtering. +- The publisher SHOULD omit `package_ids` from Identity Match and let the buyer evaluate against the full active set it has registered for `seller_agent_url`. When `package_ids` IS provided, its composition MUST be statistically independent of the current placement — sending only the page-specific subset would allow a buyer to correlate Identity Match with Context Match by comparing package sets. Two acceptable modes: + - **All-active.** Include every active package this buyer has at this publisher. + - **Fuzzed.** Include a random sample of active packages, optionally padded with synthetic non-existent IDs, drawn from a distribution that does not depend on the current placement. The silent-drop rule below makes synthetic-ID padding safe — unknown IDs do not affect response shape and cannot leak registry membership. +- When both `seller_agent_url` and `package_ids` are present, the buyer evaluates against the intersection of its registered active set and `package_ids`; unknown IDs in `package_ids` MUST be silently ignored (not error-surfaced) so the response does not leak registry membership back to the publisher. +- A publisher that maintains a cached list of all active package IDs per buyer MAY send the full set on every Identity Match request as a defense in depth, but the buyer-side resolution from `seller_agent_url` is the primary mechanism. +- The publisher performs the intersection of context match offers and identity match eligibility locally, after both responses arrive. + +### Temporal decorrelation + +- Publishers SHOULD introduce a random delay between Context Match and Identity Match requests. Recommended: 100-2000ms, uniformly distributed. +- Publishers SHOULD also randomize the order of Context Match and Identity Match: each opportunity SHOULD have a roughly equal probability of Context Match being sent first or Identity Match being sent first. A fixed order — e.g., Identity Match always after Context Match — leaks the pairing through ordering even when the delay is randomized. +- Publishers MAY batch Identity Match requests across multiple page views. +- Publishers MAY route Context Match and Identity Match through different network paths. + +### TEE attestation + +- The TMP Router SHOULD provide TEE attestation when available, proving the deployed binary matches the published source. +- Attestation documents SHOULD be available on request to publishers and auditors. +- Attestation SHOULD include measurements confirming service code integrity and isolation. + +### Consent handling + +- When `consent` is omitted from an Identity Match request, buyers MUST treat this as "consent status unknown" rather than "consent not required." +- Buyers in jurisdictions where consent is required MUST reject Identity Match requests that omit `consent`, not assume consent. + +### User token requirements + +- User tokens MUST be opaque to buyer agents. Tokens may originate from identity providers (ID5, LiveRamp, UID2) or be publisher-generated. +- User tokens MUST NOT contain PII or be reversible to PII by the buyer agent. + +## Request Authentication + +TMP requests carry a signature to prove the request originated from an authorized router. This prevents unauthorized parties from sending forged requests to providers to probe targeting logic, extract sponsored content, or manipulate frequency state. + +### Signing model + +The router signs all requests using Ed25519. Both Context Match and Identity Match requests are signed, but with different signed fields reflecting their different contents and caching characteristics. + +Signatures bind the request to a specific provider. The router signs a separate signature per fan-out target using the provider's endpoint URL from the router's provider registration. Providers MUST verify that the signed `provider_endpoint_url` matches their own advertised endpoint and reject the request otherwise. This prevents a captured signature from being replayed against a different provider in the registry within the epoch. + +The daily epoch provides replay protection — a captured signature is valid for at most ~48 hours (current + previous epoch accepted by verifiers). + +### Signature envelope + +The signature is transmitted via HTTP headers alongside the JSON body. + +| Header | Value | +|---|---| +| `X-AdCP-Signature` | Base64-encoded (URL-safe, no padding) Ed25519 signature | +| `X-AdCP-Key-Id` | Key identifier from the agent's `agent-signing-key.json` | + +#### Context Match signed fields + +Concatenated in this order, UTF-8, newline-separated: + +| Field | Source | +|---|---| +| `type` | `context_match_request` | +| `property_rid` | From the request body | +| `placement_id` | From the request body | +| `package_ids` | Sorted, comma-separated list of active package IDs | +| `provider_endpoint_url` | Provider's registered endpoint URL (exact string match with provider registration, no trailing slash) | +| `daily_epoch` | `floor(unix_timestamp / 86400)` | + +When `package_ids` is absent from the request, the signature MUST use an empty string for that field in the signed payload. + +Because the signed fields are static per placement per provider, the same signature can be cached and reused for all requests to the same `(placement_id, provider_endpoint_url)` pair within a 24-hour epoch. Cache keys MUST include `provider_endpoint_url` — reusing a signature across providers violates the binding and will fail verification. + +#### Identity Match signed fields + +The signed input is the hex-encoded SHA-256 of the [RFC 8785 JCS](https://datatracker.ietf.org/doc/html/rfc8785) serialization of the following canonical object. Using JCS eliminates delimiter-injection risk — raw `tcf_consent` / `gpp` / `us_privacy` / `package_id` values may contain any byte, but JSON string escaping in JCS renders any framing byte harmless. + +| Field | Source | +|---|---| +| `type` | `"identity_match_request"` | +| `request_id` | From the request body | +| `identities_hash` | Hex-encoded SHA-256 of the canonical `identities` bytes (see below) | +| `consent` | The request's `consent` object verbatim, or `null` when absent | +| `package_ids` | The request's `package_ids` sorted lexicographically by UTF-8 byte order | +| `provider_endpoint_url` | Provider's registered endpoint URL (exact string match with provider registration, no trailing slash). Binds the signature to a specific provider — reusing a signature across providers fails verification. | +| `daily_epoch` | `floor(unix_timestamp / 86400)` as a JSON integer | + +**Canonical `identities` bytes:** deduplicate on `(uid_type, user_token)` using byte-exact match (no case folding, no trimming), sort entries by `uid_type` (UTF-8 byte order), then by `user_token` (UTF-8 byte order), then serialize the resulting array as [RFC 8785 JCS](https://datatracker.ietf.org/doc/html/rfc8785). SHA-256 the UTF-8 bytes; hex-encode for the signed input, use raw bytes for cache keys (both conventions hash the same preimage). + +The router filters `identities` per provider before forwarding (see [Identity Match fan-out](/dist/docs/3.0.13/trusted-match/router-architecture#identity-match-fan-out)). The signature is computed over each provider's filtered `identities` set — the router re-signs per outbound forward. The same filtered `identities_hash` value is used in the cache key (see [Caching](#caching)), so each provider has its own cache partition keyed on the subset it actually received. + +Identity Match signatures include `request_id` and `identities_hash`, so they are unique per request and cannot be cached. This is intentional — Identity Match responses affect buyer-side frequency state and must be idempotent. Buyers MUST deduplicate Identity Match requests by `request_id` within the daily epoch window. A repeated `request_id` MUST return the same response without updating frequency state. Buyers SHOULD dedupe by `hash(request_id)` rather than retaining the raw identifier. + +### Signature verification + +The router MUST authenticate incoming requests from the publisher before signing and fanning out. The mechanism for publisher-to-router authentication is deployment-specific (mTLS, API key, etc.) and outside the scope of TMP signing, but MUST be enforced. This prevents a compromised publisher-side component from laundering unauthenticated requests through the router's signature. + +The router signs requests before fanning out. Providers verify signatures using the publisher's public key, obtained from the property registry. This proves the request originated from an authorized router — not a third party probing the provider's targeting logic. Providers SHOULD sample-verify rather than verify every request — Ed25519 verification adds ~30μs per request, which is small compared to the full pipeline but adds up at volume. A 5% sample rate detects fraudulent publishers within seconds while adding negligible overhead. + +On verification failure, the provider SHOULD suppress the property for a configurable period (recommended: 24 hours) and alert operations. + +### Key rotation + +Agents publish new signing keys via `agent-signing-key.json` at their well-known agent URL. Routers SHOULD cache keys with a 5-minute TTL. When a signature fails verification, the router SHOULD re-fetch the key before rejecting — the agent may have rotated. + +### Key revocation + +Rotation replaces a key; revocation kills one. Revocation is for the compromise case where a private key is known or suspected to have leaked. + +Publishers mark a compromised key by setting `revoked_at` (ISO 8601 timestamp) on the key entry in `agent-signing-key.json` and leaving the key in the trust anchor during a grace period so stale caches still find it. Verifiers MUST: + +- Reject any signature produced with a key where `revoked_at` is present and the signing epoch falls at or after the revocation timestamp. +- Re-fetch `agent-signing-key.json` on verification failure before rejecting the request, to pick up a revocation that propagated after the last cache refresh. +- Treat revocation as non-retryable for the offending request — there is no "maybe it'll work on retry" state. + +Keys may be removed from the trust anchor entirely once the cache TTL (recommended: 5 minutes) has elapsed across all verifiers. Removing the key earlier can produce a window where verifiers with a cached copy accept signatures that a fresh fetch would reject — keep the `revoked_at` marker in place until cache propagation completes. + +**Revocation limits.** Revocation propagates within 5 minutes (cache TTL) but does not retroactively invalidate signatures already accepted by providers before the revocation was observed. Captured signatures with a signing epoch before `revoked_at` remain verifiable for the remainder of the 48-hour replay window. Operators who suspect compromise SHOULD: + +- Rotate proactively on any suspicion, not only on confirmed leak. +- Keep signing keys in HSM or KMS rather than on disk to minimize compromise likelihood in the first place. +- Accept that a leaked key grants up to ~48 hours of forgery capability until daily-epoch rollover retires the signed payloads that contained it. Shorter custom epoch windows are a deployment option when this window is unacceptable. + +### Key distribution + +Publisher public keys are distributed via the property registry. Each property record includes the publisher's Ed25519 public key. Providers download the registry at startup and keep it current via incremental sync. + +## Wire Format + +Content type: `application/json` + +All TMP messages use JSON encoding. Field names and types follow the JSON Schema definitions in this specification. Implementations MUST support JSON. + +TMP messages are small (200-600 bytes per request/response). At these sizes, serialization format is less than 1% of total latency — the protocol's performance comes from smaller messages and structural separation, not encoding efficiency. JSON is universal, debuggable, and supported by every language and tool. + +## Country-Partitioned Identity + +Identity data is logically partitioned by country (ISO 3166-1 alpha-2). The protocol carries the country code on every Identity Match request and in every TMPX token (the buyer's read replica includes the country in the encrypted plaintext for data residency routing — this is buyer-internal and not visible to the publisher or router). How countries are physically grouped into database clusters is a deployment decision — the protocol does not constrain it. + +Publishers include a `country` field on Identity Match requests as a routing directive. The router uses it to select providers whose `countries` list includes that country code, then strips the field before forwarding. The buyer agent never sees the country — it is not an identity signal. + +Each provider entry declares which countries and identity types it serves via `countries` and `uid_types` fields. A multi-country buyer operates separate provider entries per cluster (e.g., one for US, one for EU countries). This enables buyers to comply with data residency requirements, subscribe publishers to only the countries they serve, and move countries between clusters without protocol changes. + +## TMPX Exposure Tokens + +TMP uses encrypted exposure tokens (TMPX) to close the frequency capping loop. The Identity Match read replica encrypts resolved identity tokens into an opaque TMPX macro that flows through creative tracking URLs. The buyer's impression pixel decrypts the token and logs per-user exposures. + +### Encryption + +TMPX uses HPKE (RFC 9180) with `mode_base`: + +| Parameter | Value | +|---|---| +| Mode | `mode_base` — encrypt with recipient's public key only | +| KEM | DHKEM(X25519, HKDF-SHA256) | +| KDF | HKDF-SHA256 | +| AEAD | ChaCha20-Poly1305 | + +The buyer's cluster master holds the recipient private key. Read replicas encrypt using the master's public key. Only the master can decrypt. Forging a TMPX token requires both the master's public key (published) and realistic identity tokens from identity providers (UID2, ID5, RampID) — fabricating these is harder than the fraud itself. Systematic frequency manipulation is detected at the IVT (Invalid Traffic) layer, not the encryption layer. + +### Binary format + +The TMPX plaintext is a compact binary structure. The type ID implicitly defines the token length — no length prefix is needed. + +**Header (16 bytes):** + +| Field | Size | Description | +|---|---|---| +| Version | 1 byte | Format version (`0x01`) | +| Timestamp | 4 bytes | uint32 Unix seconds — token creation time | +| Country | 2 bytes | ISO 3166-1 alpha-2, ASCII — identity data residency | +| Nonce | 8 bytes | Random — replay deduplication at the master | +| Count | 1 byte | Number of identity entries | + +**Entries (repeated, buyer-configured priority order):** + +| Field | Size | Description | +|---|---|---| +| Type ID | 1 byte | Fixed integer, defines token binary size | +| Token | N bytes | Raw binary identity token (size determined by Type ID) | + +**Type ID registry:** + +| ID | Token Type | Binary size | +|---|---|---| +| 1 | uid2 | 32 bytes | +| 2 | euid | 32 bytes | +| 3 | id5 | 32 bytes | +| 4 | rampid | 32 bytes | +| 5 | rampid_derived | 48 bytes | +| 6 | maid | 16 bytes | +| 7 | pairid | 32 bytes | +| 8 | hashed_email | 32 bytes | +| 9 | publisher_first_party | 32 bytes | + +Type IDs are stable — new types append, existing IDs never change. Tokens are stored in binary (UUIDs as 16 bytes, base64-encoded tokens decoded). RampID has two entries because maintained (XY, 32 bytes) and derived (Xi, 48 bytes) forms differ in size. + +If a parser encounters an unknown Type ID, it MUST stop parsing and treat the remaining entries as absent. The header Count field indicates total entries, but implementations MUST NOT assume all entries are parseable — forward compatibility requires graceful degradation when newer Type IDs are present. + +### Wire format + +The TMPX macro value uses the format `.`. The ciphertext MUST use unpadded base64url encoding (RFC 4648 section 5, no `=` padding characters). Padding characters break URL query parameters where `=` is the key-value delimiter. The `kid` (key identifier, max 8 characters) is opaque — it MUST NOT encode geographic or deployment information. It maps to a cluster master private key internally. + +**Size budget (255-char GAM macro limit):** After HPKE overhead (48 bytes) and header (16 bytes), ~120 bytes remain for identity entries. Three 32-byte tokens = 99 bytes — fits comfortably. When the buyer resolves more identities than fit the budget, the TMPX plaintext is truncated to the highest-priority entries according to buyer deployment configuration. The priority order is a buyer-side configuration concern (not protocol-level), typically ranking deterministic graphs (UID2, RampID) above probabilistic or publisher-scoped identifiers. Buyers MUST configure an explicit priority list — the default implementation MUST NOT truncate arbitrarily — and the list SHOULD be documented in the buyer's operational runbook. + +### Key management + +One X25519 keypair per cluster: + +- The cluster master holds the **private key** for decryption. +- The **public key** is published in `encryption_keys` on agent authorization entries in `adagents.json`. +- Read replicas use the public key to encrypt. No per-replica key management. + +Key rotation follows the same pattern as TMP signing keys: 5-minute cache TTL, kid prefix for versioning, 30-day grace period for old master keys. + +### Replay protection + +The 8-byte random nonce enables deduplication at the master. The master stores nonces for a configurable window (recommended: 7 days) and rejects duplicates. The nonce is inside the AEAD-protected ciphertext — intermediaries cannot observe it. + +### Caching behavior + +The TMPX token is generated once per Identity Match evaluation and accompanies the eligibility response for the `serve_window_sec` window. All impressions on eligible packages within that window share the same TMPX value (same nonce, same tokens). + +The buyer's master MUST NOT deduplicate by TMPX value or nonce within a serve window — each pixel fire is one impression. Multiple ads served to the same user in a CTV pod or a web page with multiple ad units all produce distinct pixel fires with the same TMPX token. The nonce deduplication only prevents replay of the same TMPX token *after* the serve window expires — if the same nonce appears outside its original window, it is a replay and MUST be rejected. + +### Publisher obligations + +Publishers MUST NOT parse, decrypt, or make decisions based on TMPX values. The token is opaque pass-through data substituted into creative tracking URLs exactly like other macros. For DOOH inventory, the player MAY include the opaque TMPX value in play log records transmitted to the buyer for exposure reconciliation — the publisher MUST NOT retain TMPX values beyond the transmission window. + +### Inventory-specific behavior + +- **Web, mobile, CTV (SSAI), audio (DAI):** Standard impression pixel — `{TMPX}` substituted at serve time. +- **CTV (client-side VAST):** Publisher substitutes `{TMPX}` before the VAST document reaches the player. +- **DOOH:** Play-log-based — the TMPX token is logged by the DOOH player and included in play log records rather than pixel URLs. + +## Transport + +- JSON over HTTP/2 POST. All implementations MUST use this transport. +- Each provider exposes two path-based endpoints under its base URL: `POST /context` for Context Match and `POST /identity` for Identity Match. The router dispatches requests by path, not by inspecting the message body. +- Each provider SHOULD expose `GET /health` at its base URL. The endpoint returns HTTP `200` with a JSON body `{"status": "ok"}` when the provider is ready to accept requests. Any non-`200` response or connection failure means the provider is not ready. Routers and operators use this for pre-flight checks and monitoring — it is not called in the request hot path. +- The `type` field in each message identifies the message for deserialization — routers and agents use it to select the correct schema, not for routing. Agents MUST validate that the `type` field matches the endpoint: `context_match_request` on `/context`, `identity_match_request` on `/identity`. A mismatch MUST be rejected with HTTP `400`. +- Connections SHOULD be reused via HTTP/2 multiplexing. +- The router SHOULD maintain a connection pool to each buyer agent. +- The [adcp-go](https://github.com/adcontextprotocol/adcp-go) SDK provides the reference client and server implementation. Conformance tests validate compatibility — implementations in other languages MUST pass the same test suite. + +### HTTP Status Codes + +TMP uses HTTP status codes for transport-level errors only. Application-level results (including TMP error responses) are always returned with HTTP `200`: + +| HTTP Status | Meaning | +|---|---| +| `200` | Request processed. Body contains the TMP response (success, empty result, or TMP error). | +| `400` | Malformed request (invalid JSON, missing `type` field). Not a TMP response — no body to parse. | +| `503` | Provider temporarily unavailable. The router should retry or skip. | + +This means a `200` with an empty `offers` array is a valid "no matches" response, and a `200` with a TMP error body (`"type": "error"`) is a valid application error. The router handles one response format regardless of outcome. + +## Latency + +- TMP targets sub-50ms end-to-end latency (publisher → router → agents → router → publisher). +- The router SHOULD apply adaptive per-agent timeouts based on observed latency percentiles. +- Agents that exceed the timeout are excluded from the merged response for that request. +- The router MAY preemptively skip agents whose p95 latency consistently exceeds the budget. + +## Caching + +Context Match responses are cacheable because the same packages are evaluated for every user on a given placement. The recommended cache key is `{property_rid, placement_id, provider_id}`. + +- Routers SHOULD cache Context Match responses with a TTL of **5 minutes**. +- Providers MAY include a `cache_ttl` field (integer, seconds) in Context Match responses to override the default. Routers MUST respect this value when present. +- Identity Match responses are bound by `serve_window_sec` (per-package single-shot fcap, max 300s, default 60s). Routers MAY apply an internal deduplication cache keyed on `{identities_hash, provider_id, package_ids_hash, consent_hash}`, where `identities_hash` is the SHA-256 of the canonical `identities` bytes defined in [Identity Match signed fields](#identity-match-signed-fields) (computed over the per-provider filtered subset); `package_ids_hash` is SHA-256 over the JCS serialization of the sorted `package_ids` array; `consent_hash` is SHA-256 over the JCS serialization of the request's `consent` object (or JCS `null` when the field is absent — this distinguishes "consent unknown" from an explicit-empty consent object). JCS framing prevents delimiter-injection: raw consent strings or package IDs containing `|`, `,`, or `\n` cannot collide two distinct inputs. Including the identity set ensures that adding or removing tokens produces a distinct cache entry. Including the package list hash ensures cached responses are invalidated when the active package set changes (e.g., a new media buy activates). Including the consent hash prevents eligibility decisions taken under one consent state from being served under another. The publisher's binding contract is the serve-window throttle, not the router's internal cache window. +- When a provider's targeting configuration changes (new packages, updated targeting rules), the provider SHOULD return `"cache_ttl": 0` (Context Match) or `"serve_window_sec": 1` (Identity Match) until the change has propagated, then resume normal values. +- `cache_ttl` (Context Match) has a schema-enforced maximum of 86400 seconds. `serve_window_sec` is bounded at 300 seconds — longer windows make per-package fcap too coarse for typical campaigns, shorter than the IdentityMatch round-trip wastes the throttle. + +## Conformance Levels + +### TMP Buyer Agent (Basic) + +- Supports `context_match` capability +- Responds to ContextMatchRequest with valid ContextMatchResponse +- Meets latency budget (p95 < 30ms for agent-side processing) +- Respects privacy constraints (does not log or correlate requests with identity data from other sources) + +### TMP Buyer Agent (Full) + +All of Basic, plus: +- Supports `identity_match` capability +- Responds to IdentityMatchRequest with valid IdentityMatchResponse (eligible package IDs + TTL) +- Supports rich offers (brand, price, summary, creative manifest) when the product declares matching `response_types` + +### TMP Router (Basic) + +- Configured with provider endpoints +- Fans out Context Match and Identity Match to authorized providers +- Merges responses +- Meets end-to-end latency budget (p95 < 50ms) + +### TMP Router (Trusted) + +All of Basic, plus: +- Runs in a TEE-attested environment (e.g., AWS Nitro Enclaves) +- Provides attestation documents on request, proving the deployed binary matches the published source +- Structural separation between context and identity code paths is verifiable via attestation measurements diff --git a/dist/docs/3.0.13/trusted-match/surfaces/ai-assistants.mdx b/dist/docs/3.0.13/trusted-match/surfaces/ai-assistants.mdx new file mode 100644 index 0000000000..018c1c2b7c --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/surfaces/ai-assistants.mdx @@ -0,0 +1,179 @@ +--- +title: TMP for AI Assistants +description: How TMP enables monetization of AI chat and assistant surfaces without traditional ad servers. +"og:title": "AdCP TMP for AI Assistants" +--- + +# TMP for AI Assistants + +AI assistants represent a fundamentally new ad surface. There is no impression in the traditional sense — sponsored content is woven into conversational responses. There is no ad server — the platform's language model generates the response. And there is no standard protocol for asking buyers "what should be sponsored in this conversation?" + +TMP provides that protocol. + +## How It Works Today + +Most AI platforms that monetize conversations either: + +- Partner with a single ad network and delegate all monetization decisions +- Build proprietary sponsorship logic tied to specific advertisers +- Don't monetize conversations at all + +There is no standard way for an AI platform to discover which buyer agents have relevant packages, ask them what to activate for a given conversation context, and incorporate their preferences into the response — all while protecting user privacy. + +## Context Match + +When a user sends a message in a conversation, the AI platform sends a Context Match request before generating the response: + +```json +{ + "type": "context_match_request", + "request_id": "ctx-a1b2c3d4", + "property_rid": "01916f3a-f8cb-7000-8000-000000000050", + "property_id": "chatplatform-assistant", + "property_type": "ai_assistant", + "placement_id": "chat-inline-recommendation", + "artifact_refs": [ + { "type": "custom", "value": "turn:b3c9e2" } + ], + "context_signals": { + "topics": ["479", "483", "592"], + "taxonomy_source": "iab", + "taxonomy_id": 7, + "sentiment": "positive", + "keywords": ["sneakers", "running", "recommendations"], + "language": "en", + "content_policies": ["csbs"], + "summary": "User asking for sneaker recommendations for running and casual wear" + }, + "geo": { "country": "US" } +} +``` + +Conversation turns are ephemeral — there's no public reference a buyer could independently resolve, so `artifact_refs` are typically limited to opaque turn identifiers (e.g., `turn:b3c9e2`). The platform sends `context_signals` with pre-computed classifier outputs (topics, sentiment, keywords, summary) so the buyer can evaluate relevance without seeing the raw conversation. No user identity is present. Platforms can also send the full conversation as an `artifact` for buyers that evaluate content directly — the same artifact schema used for content standards evaluation. + +The buyer agent responds with an offer: + +```json +{ + "type": "context_match_response", + "request_id": "ctx-a1b2c3d4", + "offers": [ + { + "package_id": "pkg-sneaker-reco", + "brand": { "domain": "apexathletics.example.com", "brand_id": "apex_runners" }, + "price": { "amount": 12.50, "currency": "USD", "model": "cpm" }, + "summary": "Apex Classic Low + Runner X — free shipping this month", + "creative_manifest": { + "format_id": { "agent_url": "https://chatplatform.example.com", "id": "sponsored_recommendation" }, + "assets": { + "headline": { "content": "Trending for spring" }, + "body": { "content": "Apex Classic Low — clean lines, tons of colorways. Free shipping this month." }, + "product_catalog": { "catalog_id": "catalog-sneakers", "ids": ["sku-classic-low", "sku-runner-x"] } + } + }, + "macros": { + "click_url": "https://apexathletics.example.com/classic-low?utm_source=chatplatform" + } + } + ], + "signals": { + "segments": ["sneaker_enthusiast"], + "targeting_kvs": [{ "key": "product_affinity", "value": "sneakers" }] + } +} +``` + +The buyer's offer includes `package_id` (required) along with optional fields: `brand`, `price`, `summary`, `creative_manifest`, and `macros`. For an AI assistant, the creative manifest is small enough to send inline in the real-time path. The manifest carries the text the platform can weave into the conversation and the catalog items to reference. The `summary` helps the platform judge relevance before deciding whether to incorporate the sponsored content. + +## Identity Match + +The platform sends an Identity Match request with a session token and the platform's `seller_agent_url`. The buyer resolves its active package set from `seller_agent_url`; when the platform sends `package_ids` explicitly (as below), composition MUST be independent of the current page — either all-active (every active package for this buyer on the platform) or fuzzed (a random sample padded with synthetic non-existent IDs the buyer silently drops). The page-specific subset is forbidden — it would let the buyer correlate this request with the context match by comparing package sets: + +```json +{ + "type": "identity_match_request", + "request_id": "id-e5f6g7h8", + "seller_agent_url": "https://ai-assistant.example", + "identities": [ + { "user_token": "tok_session_k2f8", "uid_type": "publisher_first_party" } + ], + "package_ids": ["pkg-sneaker-reco", "pkg-fashion-native", "pkg-athletic-wear", "pkg-outdoor-gear", "pkg-accessories-promo", "pkg-seasonal-sale"] +} +``` + +The buyer responds with the IDs of eligible packages and a TTL. The buyer computes eligibility from frequency caps, audience membership, and other signals — the reasons are opaque to the publisher. + +```json +{ + "type": "identity_match_response", + "request_id": "id-e5f6g7h8", + "eligible_package_ids": [ + "pkg-sneaker-reco", + "pkg-athletic-wear", + "pkg-outdoor-gear", + "pkg-seasonal-sale" + ], + "serve_window_sec": 120 +} +``` + +The platform intersects these results locally: only packages that appeared in both the context match offers and the `eligible_package_ids` list are activated. + +## Activation + +The AI platform incorporates the TMP result into its response generation: + +- The offer's creative manifest (headline, body text, catalog items) becomes part of the context available to the language model. The manifest is inline in the offer — no separate fetch needed. +- The platform's own relevance model decides **how** to integrate the sponsored content — as a direct recommendation, a subtle mention, or a separate sponsored card, depending on conversational flow and editorial policy. +- Ineligible packages (from Identity Match) are excluded from the generation context. +- The offer `summary` helps the platform's relevance model decide if the sponsored content fits the conversation. + +TMP tells the platform **what** sponsored content is available and relevant. The platform decides **how** to present it. This is the correct separation of concerns — the buyer knows their campaign; the platform knows their user experience. + +## Why This Matters + +AI assistants are a new ad surface that lacks the infrastructure web and mobile have built over decades. TMP provides: + +- **Standard buyer integration**: Any buyer agent that speaks TMP can activate packages on any AI platform that supports TMP. No bespoke integrations per platform. +- **Privacy by default**: The conversation content never leaves the platform as raw text. The buyer sees classified signals and topic IDs. The user's identity is handled in a separate request. +- **Platform editorial control**: The platform decides how to weave sponsored content into the conversation. TMP provides inputs; the platform controls the experience. +- **Multi-buyer support**: A platform can have packages from multiple buyer agents active simultaneously. The TMP Router handles fan-out. The platform handles selection. + +## Example Flow + +``` +User message: "What are the best sneakers for spring?" + → Platform classifies: topic=shopping.fashion.sneakers, sentiment=positive + → Platform sends Context Match to TMP Router + → Router fans out to buyer agents + → Apex Athletics agent: offer for Classic Low + Runner X, free shipping, inline creative manifest + → Spring Retailer agent: no offers (context doesn't match fashion-native targeting) + → Router returns merged response + + → (300ms later) Platform sends Identity Match with ALL buyer's active packages + → Response: eligible_package_ids includes pkg-sneaker-reco, serve_window_sec: 120 + → Router caches eligibility + + → Platform joins: pkg-sneaker-reco offer is eligible + → Platform includes offer's creative manifest in generation context + → Language model generates response: + "Great question! The Apex Classic Low is trending for spring — + clean lines, tons of colorways, and they're offering free + shipping right now. The Runner X is also a solid pick + if you want more cushion..." + → Sponsored content label applied per platform policy +``` + +## Billing and Measurement + +**Impression definition.** An impression occurs when the platform's LLM incorporates a creative manifest into its response to the user. This is analogous to a viewable impression on web — the content was rendered and presented. + +**Engagement events.** Follow-up questions about the sponsored product ("where can I buy those?", "what colors are available?") are engagement events. The platform tracks these and reports them via `get_media_buy_delivery`. + +**Click-through.** If the response includes a product URL and the user navigates to it, this is a click event. The platform tracks click-throughs using the URLs from the creative manifest's assets. + +**Billing model.** Most AI assistant packages use CPM (cost per thousand impressions) or CPA (cost per action). The platform reports delivery via `get_media_buy_delivery` like any other surface. + +**Measurement challenges.** Unlike web where viewability is standardized (MRC), AI assistant impressions don't have an industry-standard viewability definition yet. AdCP defines an impression as "creative manifest content presented to the user in the LLM's response." + +**Frequency counting.** Each impression counts toward the package's cross-publisher frequency cap. The platform reports impressions via delivery reporting; the buyer agent updates its exposure store. A user who sees a recommendation in an AI assistant and then visits a web page will have that AI impression reflected in the Identity Match eligibility check — provided the buyer's exposure store is current. diff --git a/dist/docs/3.0.13/trusted-match/surfaces/ctv.mdx b/dist/docs/3.0.13/trusted-match/surfaces/ctv.mdx new file mode 100644 index 0000000000..8c27df9d6a --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/surfaces/ctv.mdx @@ -0,0 +1,209 @@ +--- +title: TMP for CTV +description: How TMP activates packages for CTV pod composition with creative variant selection. +"og:title": "AdCP TMP for CTV" +--- + +# TMP for CTV + +Connected TV apps compose ad pods — sequences of ads that fill commercial breaks during streaming content. Pod composition means activating multiple packages simultaneously while respecting competitive separation, frequency limits, and duration constraints. TMP handles package activation and identity eligibility; the broadcaster's ad server handles pod assembly. + +## How It Works Today + +Broadcasters manage CTV deals through their ad server (FreeWheel, Google Ad Manager, SpringServe). Each deal is configured with targeting rules, competitive separation constraints, and creative rotation logic. Pod composition is handled by the ad server's pod optimization engine. There is no standard way for buyer agents to provide real-time input on which packages to activate or which creative variants to prefer for a specific pod. + +## The Four Messages + +A CTV ad break involves four TMP messages: a context match request and response (what content is playing, which packages match), then an identity match request and response (is this household eligible). The publisher joins the results locally to compose the pod. + +### Context Match Request + +When a pod break approaches, the broadcaster sends a context match request. The `placement_id` identifies the ad break position (e.g., `pre_roll`, `mid_roll_1`, `pod_break_2`). The `artifact_refs` reference the show and episode, which buyer agents use for content-level targeting. No package list is sent — the provider uses its synced package set for this placement. + +```json +{ + "type": "context_match_request", + "request_id": "ctx-9f3a-e7b2", + "property_rid": "01916f3a-a1d3-7000-8000-000000000020", + "property_id": "riverview-streaming", + "property_type": "ctv_app", + "placement_id": "mid_roll_1", + "artifact_refs": [ + { "type": "gracenote", "value": "SH032541890000" }, + { "type": "eidr", "value": "10.5240/B1A2-C3D4-E5F6-7890-1234-X" } + ] +} +``` + +Key points: + +- **Artifacts reference show and episode by industry ID.** The buyer agent can match on the show ("The Night Kitchen" is a cooking drama, good fit for food brands) via its Gracenote ID, the specific episode via its EIDR, or both. +- **No package list is sent per request.** The provider evaluates all eligible packages for this placement using its synced package set from media buy setup. The same packages are evaluated for every household — filtering by household happens in identity match. +- **Creative support is declared on the product's `trusted_match` config.** When a product's config includes creative response types, buyer agents return creative manifests so the broadcaster can render directly. + +### Context Match Response + +Each buyer agent evaluates the content context and responds with offers for packages it wants to activate. The router merges all responses. Here two buyers activated: + +```json +{ + "type": "context_match_response", + "request_id": "ctx-9f3a-e7b2", + "offers": [ + { + "package_id": "pkg-sparklean-30s", + "brand": { "domain": "sparklean.example.com" }, + "summary": "Kitchen cleaning product — contextual fit with cooking drama", + "creative_manifest": { + "format_id": { "agent_url": "https://riverview.example.com", "id": "video_30s" }, + "assets": { + "video": { + "delivery_type": "url", + "url": "https://creatives.sparklean.example/vast/kitchen-30s.xml" + } + } + } + }, + { + "package_id": "pkg-greenleaf-15s", + "summary": "Organic grocery — recipe content alignment", + "creative_manifest": { + "format_id": { "agent_url": "https://riverview.example.com", "id": "video_15s" }, + "assets": { + "video": { + "delivery_type": "url", + "url": "https://creatives.greenleaf.example/vast/spring-15s.xml" + } + } + } + } + ] +} +``` + +Vaultline (financial services) and Driftmoto (motorcycle brand) did not match the cooking drama context and are absent from the offers. + +For CTV creatives, the `creative_manifest` typically references a VAST URL rather than including the creative inline. Video assets are large; the manifest points to the external asset and the broadcaster's ad server fetches it at render time. + +### Identity Match Request + +Separately, the broadcaster sends an identity match request with a household token and the broadcaster's `seller_agent_url`. The buyer resolves its active package set from `seller_agent_url`. When the broadcaster sends `package_ids` explicitly (as below), composition MUST be independent of the current pod break — either all-active (every active package for that buyer at this broadcaster) or fuzzed (a random sample padded with synthetic non-existent IDs the buyer silently drops). The pod-break-specific subset is forbidden — it would let the buyer correlate the identity request with a specific context request by comparing package sets. + +```json +{ + "type": "identity_match_request", + "request_id": "id-7k2m-p4w1", + "seller_agent_url": "https://broadcaster.example", + "identities": [ + { "user_token": "tok_household_q7w2", "uid_type": "publisher_first_party" }, + { "user_token": "ID5*mN4pQ...", "uid_type": "id5" } + ], + "consent": { + "us_privacy": "1YNN" + }, + "package_ids": [ + "pkg-sparklean-30s", + "pkg-sparklean-display-web", + "pkg-sparklean-native-mobile", + "pkg-greenleaf-15s", + "pkg-greenleaf-display-web", + "pkg-vaultline-30s", + "pkg-vaultline-audio", + "pkg-driftmoto-15s", + "pkg-driftmoto-30s" + ] +} +``` + +The list includes packages from other surfaces (web display, mobile native, audio). The example uses all-active mode — the broadcaster maintains a cached list of all active packages per buyer and sends the full set every time. Fuzzed mode (random sample padded with synthetic IDs) is the equivalent privacy guarantee for broadcasters that prefer not to maintain that cache. + +### Identity Match Response + +Each buyer agent evaluates the household token against its own data (frequency caps, audience membership, purchase history) and returns the IDs of eligible packages plus a TTL. The buyer does not disclose the reasons — the publisher only needs to know whether the household qualifies. + +```json +{ + "type": "identity_match_response", + "request_id": "id-7k2m-p4w1", + "eligible_package_ids": [ + "pkg-sparklean-30s", + "pkg-sparklean-display-web", + "pkg-greenleaf-15s", + "pkg-greenleaf-display-web", + "pkg-vaultline-30s", + "pkg-vaultline-audio", + "pkg-driftmoto-30s" + ], + "serve_window_sec": 90 +} +``` + +The response covers all packages, not just CTV ones. The `serve_window_sec: 90` covers the duration of the ad break — the router uses cached eligibility to fill all pod slots without re-querying. The publisher extracts only the package IDs relevant to the current pod. + +## Pod Composition + +The broadcaster now has two sets of results and composes the pod locally: + +1. **Filter by context activation.** Only packages with offers from context match are candidates: Sparklean (30s) and Greenleaf (15s). Vaultline and Driftmoto did not activate. +2. **Filter by identity eligibility.** Of the context-activated packages, check household eligibility: Sparklean is eligible, Greenleaf is eligible. Both pass. +3. **Rank eligible offers.** Use the ad server's own priority and pacing rules to rank the eligible offers. +4. **Apply competitive separation.** The broadcaster's ad server enforces competitive separation rules — two brands in the same advertiser category cannot appear in the same pod. Sparklean (cleaning) and Greenleaf (grocery) are in different categories, so no conflict. +5. **Assemble the pod.** Fill the available duration. A typical mid-roll pod might be 60 seconds: + - Slot 1 (30s): Sparklean — context match, household eligible + - Slot 2 (15s): Greenleaf — context match, household eligible + - Remaining 15s: filled by other demand sources (programmatic, house ads) + +Competitive separation is the publisher's responsibility. TMP provides the activation and eligibility signals; the broadcaster's ad server applies business rules about which brands can appear together. This keeps the protocol simple and avoids encoding category taxonomies into TMP messages. + +## SSAI Integration + +Server-side ad insertion (SSAI) is the dominant CTV delivery model. The TMP Router runs server-side alongside the SSAI engine, keeping the entire activation flow off the client device. + +- **Flow.** The SSAI engine receives a pod break signal from the content stream, queries the TMP Router for context and identity matches, receives offers, and stitches VAST creatives into the stream before delivery. +- **Creative delivery.** The `creative_manifest` in Context Match responses includes VAST URLs that the SSAI engine can fetch and splice directly. No client-side ad loading is required. +- **Latency budget.** The SSAI engine's overall ad insertion budget is typically 200-500ms (larger than client-side insertion), since stitching happens before stream delivery. The TMP Router still targets sub-50ms for its portion; the extra budget gives the SSAI engine time for VAST fetching and stream stitching. +- **Companion ads.** If the VAST response includes companion creatives, the SSAI engine can pass them to the CTV app's display layer for rendering alongside the video content. + +## Show and Episode Artifact Refs + +CTV artifacts typically reference both a show and a specific episode using industry-standard identifiers: + +```json +"artifact_refs": [ + { "type": "gracenote", "value": "SH032541890000" }, + { "type": "eidr", "value": "10.5240/B1A2-C3D4-E5F6-7890-1234-X" } +] +``` + +EIDR (Entertainment Identifier Registry) provides globally unique IDs for shows, seasons, and episodes. Gracenote TMS IDs are equally valid. The key requirement: the identifier must be publicly resolvable so buyers can independently look up metadata. + +A buyer agent can use these at different granularities: + +- **Show-level targeting.** "Activate on any episode of The Night Kitchen" — the agent checks whether the Gracenote show ID is in its targeting rules. +- **Episode-level targeting.** "Activate only on the season premiere" — the agent checks the specific EIDR episode ID. +- **Genre or topic targeting.** The agent resolves genre and topic metadata from its cached artifact data. This works for broad category targeting without hard-coding specific show IDs in targeting rules. + +## Example Flow + +``` +Mid-roll break in "The Night Kitchen" S02E07 + + Context Match + --> Broadcaster sends request: show + episode artifacts, placement context + --> Sparklean agent: activate pkg-sparklean-30s (VAST creative, cooking context fit) + --> Greenleaf agent: activate pkg-greenleaf-15s (VAST creative, food content) + --> Vaultline agent: no activation (financial services, no context fit) + --> Driftmoto agent: no activation (motorcycle brand, no context fit) + + Identity Match (after temporal decorrelation — random delay + random order) + --> Broadcaster sends request: all 9 active packages across all buyers + --> Response: eligible_package_ids includes Sparklean, Greenleaf, Vaultline, Driftmoto-30s + --> serve_window_sec: 90 (covers the ad break) + + Pod Assembly (broadcaster's ad server) + --> Join: Sparklean and Greenleaf both activated and eligible + --> Competitive separation: different categories, no conflict + --> Pod: Sparklean 30s + Greenleaf 15s + 15s backfill + --> Fetch VAST creatives from manifest URLs + --> Serve pod during commercial break +``` diff --git a/dist/docs/3.0.13/trusted-match/surfaces/mobile.mdx b/dist/docs/3.0.13/trusted-match/surfaces/mobile.mdx new file mode 100644 index 0000000000..841a817328 --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/surfaces/mobile.mdx @@ -0,0 +1,268 @@ +--- +title: TMP for Mobile Apps +description: How TMP integrates with mobile mediation SDKs to activate pre-negotiated AdCP packages alongside programmatic demand. +"og:title": "AdCP TMP for Mobile Apps" +--- + +# TMP for Mobile Apps + +Mobile apps use mediation layers to select which ad network serves each impression. The mediator evaluates demand sources — ad networks, bidding partners, direct deals — and picks the winner. TMP integrates as an additional demand source within this model. It activates pre-negotiated AdCP packages that compete alongside existing network demand in the mediator's auction or waterfall. + +## How It Works Today + +A mobile publisher configures their mediation SDK with ad networks, waterfall priorities or in-app bidding rules, and placement definitions. When an ad opportunity arises, the mediator selects the best source based on expected revenue. AdCP packages have no path into this decision — they exist in the AdCP system but the mediation layer has no way to evaluate them. + +TMP bridges this gap. The publisher's app includes a TMP SDK that calls the TMP Router when an ad opportunity arises. Activated packages are passed to the mediation layer as custom demand sources with pre-negotiated CPMs. The mediator evaluates them alongside network bids using its standard selection logic. + +## Integration Model + +The TMP SDK sits between the app and the mediation layer. It does not replace the mediator — it feeds packages into it. + +``` +Ad opportunity + -> TMP SDK sends Context Match (content signals, placement context) + -> TMP SDK sends Identity Match (device token, all buyer packages) + -> TMP SDK joins results locally + -> Eligible, activated packages passed to mediator as demand sources + -> Mediator runs its auction / waterfall as usual + -> Winner serves +``` + +Two integration patterns cover most mobile ad formats: + +- **Activation**: The buyer activates a package by ID. The TMP SDK passes the package to the mediator with its pre-negotiated CPM. The mediator fetches the creative through its standard rendering path. This is the typical pattern for interstitials, rewarded video, and banners served through mediation. The product's `trusted_match` config declares activation as a supported response type. + +- **Creative**: The buyer returns a full creative manifest inline. The app renders the ad directly without going through the mediator. This is the typical pattern for native in-feed ads where the app controls the rendering. The product's `trusted_match` config declares creative as a supported response type. + +## Context Match + +When an ad opportunity arises, the TMP SDK sends a Context Match request to the router. The request describes the content context for the placement. + +### Interstitial Example + +A fitness app triggers an interstitial after a workout completes: + +#### Request + +```json +{ + "type": "context_match_request", + "request_id": "ctx-mob-7f3a91", + "property_rid": "01916f3a-b4e7-7000-8000-000000000030", + "property_id": "pulsefit-ios", + "property_type": "mobile_app", + "placement_id": "interstitial_main", + "artifact_refs": [ + { "type": "custom", "value": "screen:workout-complete" }, + { "type": "custom", "value": "screen:workout-summary" } + ] +} +``` + +No package list is sent per request. The provider evaluates all eligible packages for the `interstitial_main` placement using its synced package set from media buy setup. The same packages are evaluated for every user — filtering by user would leak identity into the context path. + +#### Response + +```json +{ + "type": "context_match_response", + "request_id": "ctx-mob-7f3a91", + "offers": [ + { + "package_id": "pkg-sports-inter-01" + }, + { + "package_id": "pkg-nutrition-inter-02", + "summary": "Post-workout recovery shake promo" + } + ] +} +``` + +Two packages activated. The sports gear and nutrition packages match the fitness content context. The telecom and auto packages did not match and are absent from the offers list. When the product's `trusted_match` config declares activation as the response type, offers carry just the `package_id` (plus an optional summary). The mediator will handle creative fetching. + +### Native Feed Example + +A recipe app shows sponsored content cards in its recipe feed: + +#### Request + +```json +{ + "type": "context_match_request", + "request_id": "ctx-mob-b2c419", + "property_rid": "01916f3a-c5f8-7000-8000-000000000031", + "property_id": "tastecraft-android", + "property_type": "mobile_app", + "placement_id": "banner_feed", + "artifact_refs": [ + { "type": "url", "value": "https://tastecraft.example.com/weeknight-pasta-recipes" } + ] +} +``` + +#### Response + +```json +{ + "type": "context_match_response", + "request_id": "ctx-mob-b2c419", + "offers": [ + { + "package_id": "pkg-grocery-native-01", + "brand": { "domain": "freshmart.example.com" }, + "price": { "amount": 6.50, "currency": "USD", "model": "cpm" }, + "summary": "Pasta night ingredients — 20% off with in-app coupon", + "creative_manifest": { + "format_id": { "agent_url": "https://tastecraft.example.com", "id": "native_card" }, + "assets": { + "headline": { "content": "Everything for pasta night" }, + "body": { "content": "Fresh basil, San Marzano tomatoes, and artisan pasta. 20% off your next order." }, + "image": { "url": "https://cdn.freshmart.example/campaigns/pasta-night-card.jpg", "width": 1200, "height": 628 }, + "cta": { "content": "Shop Now" } + } + }, + "macros": { + "campaign_ref": "fm-pasta-2026q2", + "promo_code": "PASTA20" + } + }, + { + "package_id": "pkg-kitchenware-native-02", + "brand": { "domain": "ironpan.example.com", "brand_id": "ironpan" }, + "summary": "Cast iron skillet — pairs with pasta recipes", + "creative_manifest": { + "format_id": { "agent_url": "https://tastecraft.example.com", "id": "native_card" }, + "assets": { + "headline": { "content": "The only pan you need" }, + "body": { "content": "Pre-seasoned 12-inch cast iron. Free shipping this week." }, + "image": { "url": "https://cdn.ironpan.example/campaigns/skillet-card.jpg", "width": 1200, "height": 628 }, + "cta": { "content": "Learn More" } + } + } + } + ] +} +``` + +When the product's `trusted_match` config declares creative as the response type, the buyer returns full creative details inline. The app has everything it needs to render the native card without a separate creative fetch. The meal kit package (`pkg-meal-native-03`) did not match and is absent from the response. + +## Identity Match + +The TMP SDK sends an Identity Match request with a publisher-scoped device token and the publisher's `seller_agent_url`. This request is structurally separate from Context Match — it carries no content signals and is sent with temporal decorrelation: a random delay of 100-2000ms, plus randomized order (each opportunity has roughly equal probability of Context Match or Identity Match being sent first). + +The buyer resolves its active package set from `seller_agent_url`. When the SDK sends `package_ids` explicitly (as below), composition MUST be independent of the current placement — either all-active (every active package for the buyer at this publisher) or fuzzed (a random sample padded with synthetic non-existent IDs the buyer silently drops). The placement-specific subset is forbidden — it would let the buyer correlate Identity Match with Context Match by comparing package sets. + +#### Request + +```json +{ + "type": "identity_match_request", + "request_id": "id-mob-e4d782", + "seller_agent_url": "https://mobile-publisher.example", + "identities": [ + { "user_token": "tok_idfv_a9c3e7", "uid_type": "publisher_first_party" }, + { "user_token": "A1B2C3D4-E5F6-7890-1234-567890ABCDEF", "uid_type": "maid" } + ], + "consent": { + "gpp": "DBACNYA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA" + }, + "package_ids": [ + "pkg-sports-inter-01", + "pkg-nutrition-inter-02", + "pkg-telecom-inter-03", + "pkg-auto-inter-04", + "pkg-sports-banner-05", + "pkg-nutrition-banner-06", + "pkg-sports-rewarded-07" + ] +} +``` + +Seven package IDs — the example uses all-active mode (every active package for this buyer across every placement and format, not just the two that were activated by Context Match). A fuzzed list of similar size, padded with synthetic non-existent IDs the buyer silently drops, would satisfy the same privacy invariant. + +#### Response + +```json +{ + "type": "identity_match_response", + "request_id": "id-mob-e4d782", + "eligible_package_ids": [ + "pkg-sports-inter-01", + "pkg-nutrition-inter-02", + "pkg-auto-inter-04", + "pkg-sports-banner-05", + "pkg-sports-rewarded-07" + ], + "serve_window_sec": 60 +} +``` + +Only eligible packages are listed. The buyer computes eligibility from frequency caps, audience membership, purchase history, and any other identity-based signals. The reasons are opaque to the publisher. The publisher does not learn why `pkg-telecom-inter-03` is ineligible — just that it is absent from the list. + +The `serve_window_sec` tells the router how long to cache this response. During the TTL window, the router uses cached eligibility to fill interstitials, banners, and rewarded ads without re-querying the buyer. + +## Joining and Activation + +The TMP SDK joins Context Match and Identity Match results locally. Only packages that appear in both responses — activated by context and eligible by identity — proceed to the mediator. + +### Interstitial Activation + +From the interstitial example above: + +| Package | Context Match | Identity Match | Result | +|---|---|---|---| +| `pkg-sports-inter-01` | Activated | Eligible | Passed to mediator | +| `pkg-nutrition-inter-02` | Activated | Eligible | Passed to mediator | +| `pkg-telecom-inter-03` | Not activated | Ineligible | Skipped | +| `pkg-auto-inter-04` | Not activated | Eligible | Skipped (no context match) | + +Two packages pass: `pkg-sports-inter-01` and `pkg-nutrition-inter-02`. The TMP SDK registers them as custom demand sources in the mediation layer with their pre-negotiated CPMs. + +``` +Mediation auction: + Network A bid: $6.50 CPM + Network B bid: $5.20 CPM + pkg-sports-inter-01: $8.00 CPM (pre-negotiated) + pkg-nutrition-inter-02: $7.00 CPM (pre-negotiated) + +Winner: pkg-sports-inter-01 at $8.00 CPM + -> Mediator serves the sports gear interstitial +``` + +If no AdCP package wins, the mediator serves a network ad as usual. TMP ensures AdCP packages were considered — it does not override the mediator's selection logic. + +### Native Feed Activation + +For native ads where the product's `trusted_match` config declares creative as the response type, the app renders directly from the creative manifest returned in the Context Match response. The mediator is not involved. The app intersects the context match offers with `eligible_package_ids` from the identity match, picks the best eligible offer using its own ranking logic, and renders the native card using the `creative_manifest` assets. + +## Rewarded Video + +Rewarded video follows the same activation pattern as interstitials. The placement identifies the reward slot: + +```json +{ + "type": "context_match_request", + "request_id": "ctx-mob-c8f201", + "property_rid": "01916f3a-d6a9-7000-8000-000000000032", + "property_id": "puzzlequest-ios", + "property_type": "mobile_app", + "placement_id": "rewarded_video", + "artifact_refs": [ + { "type": "custom", "value": "screen:level-complete-42" } + ] +} +``` + +The mediator handles rewarded video completion callbacks and reward granting. TMP activates packages; the mediator manages the reward lifecycle. + +## Privacy Considerations + +Mobile TMP follows the same structural separation as all surfaces: + +- **Context Match** carries content signals and placement data. No device identifiers, no user tokens, no IDFA/IDFV in the request. +- **Identity Match** carries only a publisher-scoped device token and the full list of buyer package IDs. No content signals, no screen names, no topic IDs. +- **Temporal decorrelation** between the two requests prevents timing- and order-based correlation. The TMP SDK introduces a random delay (100-2000ms) AND randomizes which request is sent first — each opportunity has roughly equal probability of Context Match or Identity Match going first. +- **Package set decorrelation**: Context Match sends no package list — the provider evaluates the same synced package set for every user on a placement. Identity Match sends all packages for the buyer. Neither path reveals which packages are relevant to the current opportunity. + +The publisher performs the intersection locally after both responses arrive. The buyer never sees the joined result. diff --git a/dist/docs/3.0.13/trusted-match/surfaces/retail-media.mdx b/dist/docs/3.0.13/trusted-match/surfaces/retail-media.mdx new file mode 100644 index 0000000000..4bee694a7e --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/surfaces/retail-media.mdx @@ -0,0 +1,129 @@ +--- +title: TMP for Retail Media +description: How TMP activates sponsored product packages with GTIN-level catalog refinement. +"og:title": "AdCP TMP for Retail Media" +--- + +# TMP for Retail Media + +Retailers manage sponsored product placements across search results, category pages, and carousels. TMP's catalog refinement capabilities make it a natural fit — buyers can specify which products to feature, which promotions to highlight, and which items to suppress, all within the bounds of pre-negotiated packages. + +## How It Works Today + +Retail media networks use internal recommendation engines to decide which sponsored products appear. Buyers set campaign-level targeting (keywords, categories, budgets) but have limited real-time control over which specific products appear in which contexts. Each retailer has its own API and optimization logic. + +## Context Match + +When a shopper views a search results page or category page, the retailer sends a Context Match request: + +```json +{ + "type": "context_match_request", + "request_id": "ctx-retail-8f3a", + "property_rid": "01916f3a-e7ba-7000-8000-000000000040", + "property_id": "grocery-retailer-web", + "property_type": "website", + "placement_id": "search-results-sponsored", + "artifact_refs": [ + { "type": "custom", "value": "search:beverages-coffee" } + ] +} +``` + +The buyer responds with an offer: + +```json +{ + "type": "context_match_response", + "request_id": "ctx-retail-8f3a", + "offers": [ + { + "package_id": "pkg-coffee-sponsored", + "brand": { "domain": "coldbrew.example.com", "brand_id": "coldbrew" }, + "price": { "amount": 2.50, "currency": "USD", "model": "cpc" }, + "summary": "Cold brew and iced latte — buy 2 get 1 free promotion", + "creative_manifest": { + "format_id": { "agent_url": "https://grocery-retailer.example.com", "id": "sponsored_product_listing" }, + "assets": { + "items": { + "type": "product", + "items": [ + { "gtin": "gtin-cold-brew-12oz", "badge": "BOGO", "image_url": "https://cdn.example.com/cold-brew-12oz.jpg" }, + { "gtin": "gtin-iced-latte-4pk", "badge": "PROMO", "image_url": "https://cdn.example.com/iced-latte-4pk.jpg" } + ] + }, + "promo_banner": { + "url": "https://cdn.example.com/banners/b2g1.png", + "width": 728, + "height": 90 + } + } + }, + "macros": { + "click_tracker": "https://track.example.com/click?pkg=coffee-sponsored", + "impression_tracker": "https://track.example.com/imp?pkg=coffee-sponsored" + } + } + ] +} +``` + +The buyer's offer summary helps the retailer judge relevance. The creative manifest is included inline with the offer, specifying which catalog items to feature, promotion badges, and rendering assets. For large creatives, the manifest references external assets via URLs rather than embedding them directly. + +## Identity Match + +The retailer sends an Identity Match request with the shopper's loyalty token and the retailer's `seller_agent_url`. The buyer resolves its active package set from `seller_agent_url`; when the retailer sends `package_ids` explicitly (as below), composition MUST be independent of the current page — either all-active (every active package for this buyer at the retailer) or fuzzed (a random sample padded with synthetic non-existent IDs the buyer silently drops). The page-specific subset is forbidden — it would let the buyer correlate this request with the context match by comparing package sets: + +```json +{ + "type": "identity_match_request", + "request_id": "id-retail-c7b2", + "seller_agent_url": "https://retailer.example", + "identities": [ + { "user_token": "tok_loyalty_m3p7", "uid_type": "publisher_first_party" }, + { "user_token": "a1b2c3d4e5f67890abcdef...", "uid_type": "hashed_email" } + ], + "package_ids": ["pkg-coffee-sponsored", "pkg-snacks-display", "pkg-dairy-promo", "pkg-bakery-seasonal", "pkg-frozen-meals", "pkg-household-q1"] +} +``` + +The buyer responds with the IDs of eligible packages and a TTL: + +```json +{ + "type": "identity_match_response", + "request_id": "id-retail-c7b2", + "eligible_package_ids": [ + "pkg-coffee-sponsored", + "pkg-snacks-display", + "pkg-bakery-seasonal", + "pkg-frozen-meals" + ], + "serve_window_sec": 60 +} +``` + +The publisher does not need to know why a user is or isn't eligible — just whether they are. Catalog items to display come from the creative manifest in the Context Match offer, not from the Identity Match response. + +## Activation + +The retailer joins both responses: + +- Accept the coffee sponsored offer +- Use the inline creative manifest from the offer for catalog items, promotion badges, and rendering assets +- Check Identity Match: is the package in `eligible_package_ids`? +- The retailer's own recommendation engine integrates the sponsored results alongside organic results + +## Example Flow + +``` +Shopper searches "cold brew" + → Retailer sends Context Match: coffee sponsored package available + → Buyer: offer with creative manifest (cold brew + iced latte items, promo banner, badges) + + → (fuzzed) Retailer sends Identity Match: loyalty token + all active package IDs + → Buyer: eligible_package_ids includes pkg-coffee-sponsored, serve_window_sec: 60 + + → Retailer joins: accept offer, render items from creative manifest + → Render sponsored carousel in search results +``` diff --git a/dist/docs/3.0.13/trusted-match/surfaces/web.mdx b/dist/docs/3.0.13/trusted-match/surfaces/web.mdx new file mode 100644 index 0000000000..45717d78de --- /dev/null +++ b/dist/docs/3.0.13/trusted-match/surfaces/web.mdx @@ -0,0 +1,227 @@ +--- +title: TMP for Web Publishers +description: How TMP activates pre-negotiated packages on web pages using Prebid and GAM. +"og:title": "AdCP TMP for Web Publishers" +--- + +# TMP for Web Publishers + +Web publishers typically run an ad server (GAM, Kevel, FreeWheel) that manages line items, targeting rules, and ad selection. TMP integrates with this infrastructure through a Prebid module — it tells the ad server which deals to activate, it does not replace the ad server. + +## How It Works Today + +A publisher running Prebid with AdCP deals has packages defined through `create_media_buy`. To activate them, the publisher manually creates corresponding line items or PMP deals in their ad server. A vendor-specific RTD module injects targeting signals by sending the full OpenRTB BidRequest to the vendor's API. This works but requires per-vendor integration and sends unnecessary data. + +TMP replaces vendor-specific RTD modules with a single Prebid module that speaks a standard protocol. Buyer agents evaluate context and identity separately, and the publisher joins the results locally before passing instructions to GAM. + +## Context Match + +When a page loads, the TMP Prebid module sends a Context Match request to the router. The request includes the page context. No package list is sent — the provider uses its synced package set for this placement. + +### Context Match Request + +```json +{ + "type": "context_match_request", + "request_id": "ctx-7f2a-oakwood-91b3", + "property_rid": "01916f3a-9c4e-7000-8000-000000000010", + "property_id": "oakwood-publishing-main", + "property_type": "website", + "placement_id": "article-sidebar-300x250", + "artifact_refs": [ + { "type": "url", "value": "https://oakwood.example.com/sustainable-kitchen-2026-03" } + ] +} +``` + +Key points: + +- No package list is sent per request. The provider evaluates all eligible packages for this placement using its synced package set from media buy setup. The same packages are evaluated for every user, preventing identity leakage into the context path and enabling response caching. +- `artifact_refs` references content by type and value. The buyer agent resolves artifact metadata from its cache — no inline signals needed on the request. + +### Context Match Response + +The router fans out to each buyer agent and merges the responses. Each buyer returns offers for packages whose targeting matched the content context, plus response-level signals that flow through to GAM as key-values. + +```json +{ + "type": "context_match_response", + "request_id": "ctx-7f2a-oakwood-91b3", + "offers": [ + { "package_id": "pkg-display-0041" }, + { "package_id": "pkg-native-0078" } + ], + "signals": { + "segments": ["sustainability", "home_cooking"], + "targeting_kvs": [ + { "key": "adcp_seg", "value": "sustainability" }, + { "key": "adcp_seg", "value": "home_cooking" }, + { "key": "adcp_pkg", "value": "pkg-display-0041" }, + { "key": "adcp_pkg", "value": "pkg-native-0078" } + ] + } +} +``` + +Key points: + +- `offers` contains one entry per activated package. The provider's synced set for this placement included three packages — these two matched the kitchen/sustainability context, the third (`pkg-display-0103`) did not and is absent. +- For web/GAM activation, offers are simple — just `package_id`. Richer fields (`brand`, `price`, `summary`, `creative_manifest`, `macros`) are available for integrations that need them but are not required. +- `signals.targeting_kvs` are the key-value pairs that the Prebid module sets on the GAM ad request. GAM line items are configured to match on these keys. + +## Identity Match + +Separately, the TMP Prebid module sends an Identity Match request with the user's identity tokens and the publisher's `seller_agent_url`. This request carries no page context. The buyer resolves its active package set from `seller_agent_url`; when the module sends `package_ids` explicitly (as below), composition MUST be independent of the current page — either all-active (every active package for that buyer at this publisher) or fuzzed (a random sample padded with synthetic non-existent IDs the buyer will silently drop). The page-specific subset is forbidden. + +### Identity Match Request + +```json +{ + "type": "identity_match_request", + "request_id": "id-3k9p-oakwood-d4f1", + "seller_agent_url": "https://oakwood.example", + "identities": [ + { "user_token": "tok_uid2_8f2a3b7c", "uid_type": "uid2" }, + { "user_token": "XY1a2b3c4d5e6f7g8h9i0jKlMnOpQrSt", "uid_type": "rampid" }, + { "user_token": "ID5*aB3xY9kL...", "uid_type": "id5" } + ], + "package_ids": [ + "pkg-display-0041", + "pkg-display-0042", + "pkg-display-0043", + "pkg-native-0078", + "pkg-native-0079", + "pkg-display-0103", + "pkg-display-0104", + "pkg-video-0201" + ] +} +``` + +Key points: + +- `request_id` is unrelated to the context match `request_id`. The two must not be derivable from each other. +- `package_ids` example shows all-active mode: every active package for the buyer across the entire site, not just the three that were on this placement's context match. The page-specific subset is forbidden — it would let the buyer correlate identity with context by comparing package sets. Fuzzed mode (a random sample padded with synthetic IDs the buyer silently drops) is also acceptable. +- `identities` carries every token the publisher has available (UID2, ID5, LiveRamp, hashed email, publisher first-party). Each token is opaque — the buyer cannot reverse it to PII. Sending the full set maximizes match rate because different buyers resolve on different graphs. + +### Identity Match Response + +The buyer evaluates the user against all requested packages and returns the IDs of eligible packages, plus a TTL defining how long the router can cache this response. + +```json +{ + "type": "identity_match_response", + "request_id": "id-3k9p-oakwood-d4f1", + "eligible_package_ids": [ + "pkg-display-0041", + "pkg-display-0042", + "pkg-native-0078", + "pkg-native-0079", + "pkg-display-0104" + ], + "serve_window_sec": 60 +} +``` + +Key points: + +- Only eligible packages are listed. Packages absent from the list (e.g., `pkg-display-0043`, `pkg-display-0103`, `pkg-video-0201`) are ineligible. The buyer computes eligibility from frequency caps, audience membership, purchase history, and any other identity-based signals. The reasons are opaque to the publisher. +- `serve_window_sec` tells the router how long to cache this response. During that window, the router returns cached eligibility without re-querying the buyer. The publisher uses cached eligibility to allocate across all placements on the page. +- There is no `frequency_capped`, `audience_match`, or `recency` field. The buyer's internal reasons stay with the buyer. + +## Activation: Joining Context and Identity + +The publisher joins context match and identity match locally. This is where the two halves come together — the router never sees both at the same time. + +### Step 1: Intersect + +Take the offers from context match and filter by identity match eligibility. + +| Package | Context Match | Identity Match | Result | +|---|---|---|---| +| `pkg-display-0041` | Offered | Eligible | Activate | +| `pkg-native-0078` | Offered | Eligible | Activate | +| `pkg-display-0103` | Not offered | Not eligible | Skip | + +Only two packages survive: `pkg-display-0041` and `pkg-native-0078`. + +### Step 2: Set GAM Targeting + +The Prebid module takes the context match `signals.targeting_kvs` and sets them on the GAM ad request as key-value pairs: + +``` +adcp_seg = sustainability, home_cooking +adcp_pkg = pkg-display-0041, pkg-native-0078 +``` + +GAM line items are pre-configured to target on `adcp_pkg` values. When the ad request arrives with `adcp_pkg=pkg-display-0041`, GAM matches it to the corresponding line item and serves the creative. + +### Step 3: GAM Selects + +GAM applies its own priority rules, competitive exclusions, and pacing logic across all eligible line items — including both TMP-activated deals and other demand sources. TMP does not override GAM's ad selection; it provides the inputs. + +## GAM Line Item Configuration + +For each active package, the publisher creates a GAM line item targeting `adcp_pkg = `. This is the link between TMP activation and GAM ad selection. + +- **Line item type.** Sponsorship or Standard, depending on deal terms from `create_media_buy`. Guaranteed deals use Sponsorship; non-guaranteed deals use Standard. +- **Priority.** Set based on deal type. Guaranteed deals at priority 4-8, non-guaranteed at priority 12-16. This determines how TMP demand competes with other line items in GAM's selection logic. +- **Creative assignment.** Reference pre-synced creatives from `sync_creatives`, or use the creative manifest from the Context Match response if the buyer provides one. +- **Lifecycle.** When a media buy ends or is cancelled, deactivate the corresponding line item. The router stops including the package in Identity Match within 1 hour. +- **Automation.** Publishers can automate line item creation by listening for new `create_media_buy` completions and mapping package details to GAM API calls. The package ID, deal type, priority, and creative references are all available from the media buy response. + +## Prebid Integration + +The TMP Prebid module is a Real-Time Data (RTD) module that replaces vendor-specific RTD modules. It is defined by the [TMP Prebid proposal](/specs/prebid-tmp-proposal) and follows the standard Prebid RTD module interface. It handles the full flow: + +1. **On auction init**: Send Context Match request to the TMP router for each placement on the page. +2. **On context match response**: Store offers and targeting signals. +3. **After temporal decorrelation**: Send Identity Match request with the user's identity tokens and ALL active package IDs for each buyer. Decorrelation has two parts — a random 100-2000ms delay AND randomized order: each auction has roughly equal probability of the Identity Match request being sent first or the Context Match request being sent first. +4. **On identity match response**: Join with context match results. Set targeting key-values on the ad unit. +5. **On bid request**: GAM receives the enriched ad request with TMP targeting keys and selects line items as normal. + +The temporal decorrelation between context and identity requests is a privacy measure. Random delay alone is insufficient — fixed ordering (Identity always after Context) leaks the pairing through ordering. The module randomizes both the delay and which request is sent first. + +## Sequence Diagram + +``` +Page Load + | + |-- Prebid TMP Module ---> TMP Router (Context) + | |-- fan out --> Buyer Agent A + | |-- fan out --> Buyer Agent B + | |<- merge <--- offers + signals + |<--- Context Match Response -- + | + | (100-2000ms random delay; Context/Identity order randomized + | per auction — diagram shows Context-first; Identity-first + | runs the two phases in reverse) + | + |-- Prebid TMP Module ---> TMP Router (Identity) + | |-- fan out --> Buyer Agent A + | |-- fan out --> Buyer Agent B + | |<- merge <--- eligibility + |<--- Identity Match Response -- + | + |-- Join locally: intersect offers with eligibility + |-- Set targeting KVs on GAM ad request + |-- GAM selects and serves ad +``` + +## Coexistence with OpenRTB + +Most publishers run TMP alongside Prebid header bidding, which sources demand via OpenRTB. The two systems are complementary. + +- **TMP as additional demand.** TMP packages appear as line items in GAM, competing with Prebid line items on priority and price. GAM handles the yield decision — TMP does not replace Prebid, it adds pre-negotiated demand. +- **Competitive exclusion.** Configure GAM competitive exclusion rules to prevent conflicting brands from appearing together across TMP and Prebid demand sources. +- **Revenue attribution.** TMP-activated impressions are tracked via `get_media_buy_delivery`, while Prebid impressions flow through existing SSP reporting. Publishers reconcile in their BI layer. +- **Timeout independence.** TMP and Prebid requests run in parallel. TMP timeout (50ms) is typically faster than Prebid timeout (1000-1500ms), so TMP results are ready before GAM needs them. + +## Privacy Constraints for Web + +These constraints apply to all surfaces but are worth restating for the web case: + +- **Context match carries no user data.** No cookies, no user tokens, no IP addresses. The provider evaluates the same synced package set for every user on a placement. +- **Identity match carries no page data.** No URLs, no content signals, no placement IDs. The `package_ids` list, when sent, has composition independent of the current page (all-active or fuzzed) — never the page-specific subset. +- **The publisher joins locally.** The router never sees both context and identity for the same impression. Only the publisher, who already has both the page context and the user identity, performs the intersection. +- **Temporal decorrelation.** A random delay between the two requests AND a randomized order (Context Match or Identity Match equally likely to be sent first) prevent timing- and order-based correlation at the network level. Random delay alone is insufficient because a fixed order leaks the pairing through ordering. diff --git a/dist/docs/3.0.13/verification/overview.mdx b/dist/docs/3.0.13/verification/overview.mdx new file mode 100644 index 0000000000..401687a201 --- /dev/null +++ b/dist/docs/3.0.13/verification/overview.mdx @@ -0,0 +1,260 @@ +--- +title: Seller verification +sidebarTitle: Seller verification +"og:image": /images/walkthrough/verification-01-unfamiliar-counter.png +description: "How a buyer verifies an unfamiliar seller end-to-end — brand.json, adagents.json, and request signing as one chain, with bounded honesty about what the chain does not prove." +"og:title": "AdCP — Seller verification" +--- + +Sam stands at a wide marble counter facing a confident agent in a sharp blazer holding a clipboard of glossy ad placements — but the counter behind the agent is empty, with no logo, no signage, and no one else in sight + +A `get_products` response just hit Sam's orchestrator. The seller — Northwind Media — quoted CTV inventory across StreamHaus, a sports network Acme Outdoor wants to be on. The CPMs look fair, the avails fit the flight, and the response arrived in under a second. + +Sam has never transacted with Northwind. He has no idea whether the agent that signed this response is actually authorized to sell StreamHaus inventory, whether StreamHaus is a real publisher under a parent house he recognizes, or whether a clever attacker registered `northw1nd.example` last week and is about to walk away with \$25,000. + +He doesn't need Northwind to convince him with a sales deck. He needs the protocol to make the chain verifiable in code — every link from the signing key on the response, through Northwind's brand identity, to StreamHaus's authorization, back to a parent house he can recognize. His buyer agent walks the chain automatically; Sam reads the verdict. + +This walkthrough follows that chain through Sam's eyes. + + +**What this verifies and what it doesn't.** The chain answers *who is authorized to sell*. It does not answer *who the legal entity behind the agent is* (KYC, real operator), *whether the avails reflect reality at delivery time* (catalog accuracy, CPM, delivery), or *whether the hosting infrastructure can be trusted* (DNS, CDN, registrar). The [bounded-honesty step](#step-5-know-what-the-chain-does-not-prove) names every limit explicitly. This is the C2PA "claim-not-certification" posture applied to inventory — the protocol carries the authorization claim and makes it verifiable, and stops there. + + +## The chain at a glance + +Three discoverable surfaces and one cryptographic check. Sam's agent runs them in whatever order is convenient: + +| Surface | Source | Question it answers | +|---|---|---| +| RFC 9421 signature | Response headers | "Did this response come from the key it claims?" | +| `brand.json` | `northwind.example/.well-known/brand.json` | "Who is Northwind, and what is it claiming to represent?" | +| `adagents.json` | `streamhaus.example/.well-known/adagents.json` | "Does the publisher authorize Northwind to sell its inventory?" | +| Mutual assertion | Both `brand.json` files cross-referencing | "Do the two sides of the relationship agree?" | + +The chain is bilateral by design: each fact is asserted by exactly the party with authority over it. The publisher decides who can sell its inventory. The brand owner decides what it owns. The seller decides which key signs its responses. No third-party registry adjudicates between them — a misbehaving authorized seller is remediated by the publisher revoking the `adagents.json` entry, not by an in-protocol claim check. + +Only one of the four steps below is cryptographically grounded (the signature). The other three are integrity-checked discoveries — string-equality matches against authoritative files at well-known locations. The chain is only as strong as the publisher's and seller's control of their own DNS, hosting, and well-known endpoints. See [Step 5](#step-5-know-what-the-chain-does-not-prove) for what that implies. + +## Step 1: Verify the response signature + +Sam holds a paper response up to a lamp — a wax seal in the corner glows under the light, revealing a cryptographic pattern that matches a key card he pulls from a folder labeled adagents.json + +The `get_products` response carries an RFC 9421 `Signature` and `Signature-Input` header. The `keyid` parameter points at a JWK in Northwind's published JWKS. Sam's client verifies the signature before parsing the body: + +```javascript +const response = await fetch("https://northwind.example/mcp", { /* get_products call */ }); + +const verified = await verifyMessageSignature(response, { + fetchJwks: (keyid) => fetchJwksForAgent("northwind.example", keyid), + requiredFields: ["@method", "@target-uri", "content-digest", "@authority"], + requireCreated: true, // RFC 9421 `created` parameter MUST be present + maxAge: 300, // seconds — receiver MUST enforce a window +}); + +if (!verified) throw new Error("signature failed — discard response"); +``` + +A pass tells Sam one narrow thing: the entity holding the private key paired with `keyid` produced this response, and the response has not been tampered with in transit. It does not yet tell him that `keyid` belongs to the legitimate Northwind. That binding comes from `adagents.json` in step 3. + + + +If the signature fails, every other check is wasted work — the response cannot be trusted to even identify itself correctly. Verifying first also gives Sam a clean abort: he discards the response before any business logic touches it. + +`maxAge` and `requireCreated` are not optional — without a freshness window the same signed response can be replayed indefinitely. Tune `maxAge` to your clock-skew budget; 300s is a common starting point. + +In AdCP 3.0, signing on `get_products` is RECOMMENDED. Mandatory signing on spend-committing operations is tracked under [#2307](https://github.com/adcontextprotocol/adcp/issues/2307) for 4.0. Deployments that require signing today enforce it at the platform layer. + + + +## Step 2: Read Northwind's brand.json + +Sam opens a leather-bound folder on Northwind's reception desk — inside is a single embossed page declaring the agency's portfolio, signing keys, and authorized operators, with a glowing seal at the top + +Sam's agent fetches `https://northwind.example/.well-known/brand.json`. This is Northwind's self-declaration: who it is and where its signing keys live. + +```json +{ + "$schema": "/schemas/3.0.13/brand.json", + "version": "1.0", + "id": "northwind_media", + "names": [{ "en_US": "Northwind Media" }], + "url": "https://northwind.example", + "keller_type": "master", + "industries": ["advertising"], + "agents": [ + { + "type": "sales", + "id": "northwind_sales", + "url": "https://northwind.example/mcp", + "jwks_uri": "https://northwind.example/.well-known/jwks.json" + } + ] +} +``` + +Two things matter here: + +1. **The `keyid` from step 1 must resolve in `https://northwind.example/.well-known/jwks.json`** — the JWKS Northwind's own brand.json points at. Sam now has a binding from signature to a self-declared brand identity. +2. **Northwind is a standalone agency** — no `house_domain` field. There is no parent house claim to verify on Northwind's side. The authorization claim that matters lives on the publisher's side, in step 3. + + +`brand.json` is published over HTTPS by the entity it describes. A buyer agent that pipes raw fields into an LLM prompt without schema-validating them first is taking adversarial input from a counterparty. Validate against the [brand.json schema](/dist/docs/3.0.13/brand-protocol/brand-json) before parsing, and never pass attacker-controlled string fields (`names`, `description`, custom keys) into an LLM context without sanitization. + + +## Step 3: Confirm against StreamHaus's adagents.json + +Sam holds two documents side by side — Northwind's brand.json on the left listing StreamHaus, and StreamHaus's adagents.json on the right listing Northwind — and the matching delegation_type field on both glows green as the chain locks in + +Sam's agent fetches `https://streamhaus.example/.well-known/adagents.json` — the publisher's own declaration of who is authorized to sell its inventory: + +```json +{ + "$schema": "/schemas/3.0.13/adagents.json", + "contact": { + "name": "StreamHaus Publishing", + "email": "adops@streamhaus.example", + "domain": "streamhaus.example" + }, + "properties": [ + { + "property_id": "streamhaus_ctv", + "property_type": "ctv_app", + "name": "StreamHaus CTV App", + "publisher_domain": "streamhaus.example", + "identifiers": [{ "type": "roku_store_id", "value": "12345" }] + } + ], + "authorized_agents": [ + { + "url": "https://northwind.example/mcp", + "authorized_for": "StreamHaus CTV inventory via delegated authority", + "authorization_type": "property_ids", + "property_ids": ["streamhaus_ctv"], + "delegation_type": "delegated", + "signing_keys": [ + { + "kid": "northwind-sell-prod-2026", + "kty": "OKP", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "Xe2lAKRJR_zr3FQRdSNwp3zsrv_IXnVCWJXDcWXwkLI", + "use": "sig" + } + ] + } + ], + "last_updated": "2026-04-12T10:00:00Z" +} +``` + +This is the bilateral lock: + +- **`url` matches** the MCP endpoint Northwind's `brand.json` named in `agents[].url`. Same agent on both sides. +- **`delegation_type: "delegated"`** declares the commercial relationship — Northwind is authorized to sell on StreamHaus's behalf. The enum is `direct | delegated | ad_network`; the publisher chooses which fits. +- **`signing_keys[]`** contains the JWK whose `kid` matches the one used in step 1's signature. This is the link Sam was missing: StreamHaus, the publisher, attests in its own file that this specific public key may sign on its behalf. + +Now Sam has the answer to "who is authorized to sell": **the entity that signed this response is named in the publisher's own authorization file, with a matching commercial relationship and an explicit signing-key authorization**. The chain is closed. + + + +The brand-protocol [mutual-assertion model](https://github.com/adcontextprotocol/adcp/issues/3533) produces a discrete signal Sam's downstream logic can act on: + +| State | Meaning | Buyer action | +|---|---|---| +| `inline` | The seller is the brand owner — no delegation involved | Proceed; nothing to delegate | +| `mutual_assertion` | Both sides published matching declarations | Proceed | +| `one_sided_brand` | The seller's brand.json claims a publisher; the publisher hasn't reciprocated | **Do not treat as authorization.** Any domain can claim any publisher unilaterally. | +| `one_sided_house` | The publisher's adagents.json names the seller; the seller's brand.json doesn't acknowledge | Hold for human review | +| `standalone` | Neither side publishes — bearer-token trust only | Out-of-band authorization or refuse | + +Sam's chain resolves to `mutual_assertion` — the strongest state short of inline. Only `inline` and `mutual_assertion` close the chain. + + + +## Step 4: Walk the parent house + +Sam stands in front of a wall display showing a brand-portfolio hierarchy — a large parent-house emblem at top, with the publisher emblem below it connected by a glowing teal line, and other sibling sub-brand emblems branching off the parent + +Acme Outdoor's inclusion list resolves at the parent-house level — they trust Sportshaus Holdings' family of brands. StreamHaus is a sub-brand, so Sam's agent walks one hop further. + +StreamHaus's own brand.json declares its parent: + +```json +{ + "$schema": "/schemas/3.0.13/brand.json", + "version": "1.0", + "id": "streamhaus", + "names": [{ "en_US": "StreamHaus" }], + "url": "https://streamhaus.example", + "house_domain": "sportshaus-holdings.example", + "keller_type": "endorsed", + "industries": ["media", "broadcasting"] +} +``` + +Then the parent's brand.json reciprocates: + +```json +{ + "$schema": "/schemas/3.0.13/brand.json", + "version": "1.0", + "house": { + "domain": "sportshaus-holdings.example", + "name": "Sportshaus Holdings", + "architecture": "branded_house" + }, + "brand_refs": [ + { "domain": "streamhaus.example", "brand_id": "streamhaus", "effective_at": "2025-01-01T00:00:00Z" }, + { "domain": "courtsidehq.example", "brand_id": "courtsidehq" } + ] +} +``` + +The reciprocity rule: **StreamHaus's `house_domain` ↔ Sportshaus Holdings' `brand_refs[].domain`**. Both sides agree. + +Two distinct concepts ride along this step, and the doc is careful not to conflate them: + +- **The `keller_type` on the child** (`endorsed`) describes the brand-architecture relationship — how the sub-brand is positioned beside the parent. It is Keller-architecture metadata, not a commercial authorization. +- **The commercial relationship that lets Northwind sell** is `delegation_type: "delegated"` from step 3, which lives on the publisher (StreamHaus), not on the parent house. + +Sportshaus Holdings is on Acme Outdoor's inclusion list. The chain Sam's agent just walked is: signed response → Northwind's `brand.json` → StreamHaus's `adagents.json` → StreamHaus's and Sportshaus Holdings' mutual `brand.json` declarations. Four discoverable surfaces, one cryptographic anchor, zero phone calls for the authorization question. + +## Step 5: Know what the chain does not prove + +Sam sits at his desk with the sealed document beside him, holding his phone to his ear — the technical chain is complete, and now he's calling a person at the publisher to confirm the human-layer details the protocol cannot attest + +The authorization chain is closed. Northwind is for the first time a counterparty Acme Outdoor will actually transact with at meaningful spend, so Sam picks up the phone. Not for the protocol's sake — the chain told him what it can tell him. For everything the chain can't. + +| The chain proves | The chain does not prove | +|---|---| +| Northwind is authorized to sell StreamHaus inventory under a `delegated` relationship | A real human at Northwind operates this agent and answers for it | +| The signing key is named by both the seller and the publisher | The operator behind that key has been KYC'd by anyone Sam trusts | +| StreamHaus declares Sportshaus Holdings as its parent, and the parent reciprocates | The legal counterparty matches who Sam thinks he's transacting with | +| The response was not tampered with in transit | The avails in the response will be available on the flight start date, or the CPM quoted will hold | +| Northwind's domain controls the signing key today | The key cannot be rotated to an attacker tomorrow — first-encounter trust is TOFU until [key transparency](https://github.com/adcontextprotocol/adcp/issues/3925) lands in 4.0 | + +Three named gaps live on the right side of that table. + +**The human-layer gap.** AdCP does not carry an operator/human KYC primitive. The cryptographic chain says "this domain is authorized to sell, and this key signed this response" — it does not say "a verified human at a verified company is on the other side of this agent." KYC is the membership and account layer. For a new counterparty above Acme's threshold, Sam still escalates to a human check, exactly as he would for any meaningful new vendor. + +**The hosting-layer gap.** The chain trusts whoever controls `northwind.example`, its DNS, TLS, and CDN. A registrar takeover, a CDN compromise, or a mis-issued TLS certificate substitutes the entire chain — the attacker serves attacker-controlled `brand.json`, `adagents.json`, and JWKS over a valid-looking pipeline. There is no public key-transparency log in 3.x; first-encounter trust is trust-on-first-use, and revocation is detectable only by re-fetching. A buyer client that has previously transacted with Northwind pins the seen `kid`s and warns on rotation; a buyer client on first encounter does not have that signal. See [#3925](https://github.com/adcontextprotocol/adcp/issues/3925). + +**The delivery-time gap.** Catalog accuracy is not protocol-attested. Publishers do not sign individual product entries, and per-product attestation does not match how inventory operates in production — forecasts drift, prices move, supply is dynamic. A misbehaving authorized seller is remediated by the publisher revoking the `adagents.json` entry, not by an in-protocol claim check. Delivery-time truth lives in [measurement](https://github.com/adcontextprotocol/adcp/issues/2391) and billing reconciliation. + +This is the C2PA "claim-not-certification" posture. The protocol carries the authorization claim and makes it cryptographically verifiable. It does not — and at the protocol layer should not — replace the human-layer, hosting-layer, or delivery-layer checks a buyer would do for any meaningful new counterparty. + +## What Sam does next + +Sam's client logs the verification result with the `get_products` response — including the captured `brand.json`, `adagents.json`, and JWKS bytes at decision time, not just pointers to live files. Months from now, an auditor can re-verify the signature against those captured artifacts and reproduce Sam's decision exactly. Re-fetching the live `.well-known/` files is not sufficient — they are mutable, and a single key rotation or domain transfer would invalidate a naive replay. + +The verification result and the five-state trust signal travel with the candidate plan into Sam's [governance flow](/dist/docs/3.0.13/governance/overview), where Jordan's governance agent applies Acme's policy checks before any spend is committed. + +## Where to go from here + +- [brand.json reference](/dist/docs/3.0.13/brand-protocol/brand-json) — full schema for self-declarations, parent-house portfolios, and Keller architecture metadata +- [adagents.json reference](/dist/docs/3.0.13/governance/property/adagents) — publisher-side authorization, the `authorization_type` discriminator, and the `signing_keys[]` JWK shape +- [verify_brand_claim](/dist/docs/3.0.13/brand-protocol/tasks/verify_brand_claim) — Tier-2 implementer guide for delegating verification to a brand agent +- [Security model](/dist/docs/3.0.13/building/concepts/security-model) — three-party governance and trust posture +- [Request signing](/dist/docs/3.0.13/building/by-layer/L1/request-signing) — RFC 9421 details, key rotation, transparency-log roadmap +- [Trust & Security](/dist/docs/3.0.13/trust) — CISO-facing surface map; this walkthrough is the buyer-facing companion +- [AAO Verified](/dist/docs/3.0.13/building/verification/aao-verified) — continuous behavioral conformance attestation, layered on top of the identity chain above