From 0ba9f541e1774b59f856ad3f7df2c650ddf04bf0 Mon Sep 17 00:00:00 2001 From: "aao-release-bot[bot]" <280565558+aao-release-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:38:01 +0000 Subject: [PATCH] chore: snapshot docs for v3.3.0-beta.3 --- .dockerignore | 2 + dist/docs/3.3.0-beta.3/aao/aao-admins.mdx | 84 + dist/docs/3.3.0-beta.3/aao/addie-tools.mdx | 2577 +++++++++++++++ dist/docs/3.3.0-beta.3/aao/connect-addie.mdx | 149 + dist/docs/3.3.0-beta.3/aao/directory-api.mdx | 267 ++ dist/docs/3.3.0-beta.3/aao/org-admins.mdx | 76 + dist/docs/3.3.0-beta.3/aao/users.mdx | 74 + .../accounts/buyer-agent-self-onboarding.mdx | 213 ++ dist/docs/3.3.0-beta.3/accounts/overview.mdx | 455 +++ .../principal-registration-walkthrough.mdx | 437 +++ .../accounts/provisioning-walkthrough.mdx | 393 +++ .../accounts/tasks/get_account_financials.mdx | 257 ++ .../accounts/tasks/list_account_changes.mdx | 311 ++ .../accounts/tasks/list_accounts.mdx | 297 ++ .../accounts/tasks/report_usage.mdx | 197 ++ .../accounts/tasks/request_account.mdx | 237 ++ .../accounts/tasks/sync_accounts.mdx | 734 +++++ .../accounts/tasks/sync_governance.mdx | 315 ++ dist/docs/3.3.0-beta.3/ai-disclosure.mdx | 101 + .../brand-protocol/brand-json.mdx | 1497 +++++++++ .../brand-protocol/building-a-brand-agent.mdx | 867 +++++ .../brand-protocol/for-advertisers.mdx | 82 + .../brand-protocol/for-rights-holders.mdx | 123 + .../3.3.0-beta.3/brand-protocol/index.mdx | 113 + .../brand-protocol/key-concepts.mdx | 304 ++ .../brand-protocol/protect-your-brand.mdx | 189 ++ .../brand-protocol/seller-setup.mdx | 236 ++ .../brand-protocol/tasks/acquire_rights.mdx | 342 ++ .../tasks/get_brand_identity.mdx | 239 ++ .../brand-protocol/tasks/get_rights.mdx | 176 ++ .../brand-protocol/tasks/search_brands.mdx | 68 + .../brand-protocol/tasks/update_rights.mdx | 172 + .../tasks/verify_brand_claim.mdx | 398 +++ .../tasks/verify_brand_claims.mdx | 163 + .../brand-protocol/ui-guidance.mdx | 164 + .../walkthrough-rights-licensing.mdx | 600 ++++ .../building/by-layer/L0/a2a-guide.mdx | 1042 ++++++ .../by-layer/L0/a2a-profile-extension.mdx | 318 ++ .../by-layer/L0/a2a-response-extraction.mdx | 293 ++ .../by-layer/L0/a2a-response-format.mdx | 685 ++++ .../building/by-layer/L0/index.mdx | 28 + .../building/by-layer/L0/mcp-guide.mdx | 839 +++++ .../by-layer/L0/mcp-response-extraction.mdx | 174 + .../building/by-layer/L0/schemas.mdx | 471 +++ .../building/by-layer/L1/index.mdx | 27 + .../L1/production-security-profile.mdx | 134 + .../building/by-layer/L1/request-signing.mdx | 923 ++++++ .../building/by-layer/L1/security.mdx | 2808 +++++++++++++++++ .../building/by-layer/L1/wba-profile.mdx | 303 ++ .../by-layer/L1/webhook-verifier-tuning.mdx | 149 + .../building/by-layer/L2/account-state.mdx | 247 ++ .../by-layer/L2/accounts-and-agents.mdx | 704 +++++ .../building/by-layer/L2/authentication.mdx | 374 +++ .../building/by-layer/L2/context-sessions.mdx | 310 ++ .../building/by-layer/L2/index.mdx | 26 + .../L3/async-identity-and-convergence.mdx | 284 ++ .../building/by-layer/L3/async-operations.mdx | 365 +++ .../by-layer/L3/comply-test-controller.mdx | 1563 +++++++++ .../building/by-layer/L3/error-handling.mdx | 1009 ++++++ .../building/by-layer/L3/index.mdx | 33 + .../building/by-layer/L3/task-lifecycle.mdx | 320 ++ .../building/by-layer/L3/webhooks.mdx | 753 +++++ .../building/by-layer/L4/build-a-caller.mdx | 230 ++ .../building/by-layer/L4/build-an-agent.mdx | 220 ++ .../building/by-layer/L4/choose-your-sdk.mdx | 256 ++ .../building/by-layer/L4/index.mdx | 17 + .../by-layer/L4/migrate-from-hand-rolled.mdx | 224 ++ .../building/concepts/adcp-vs-openrtb.mdx | 122 + .../concepts/how-agents-communicate.mdx | 180 ++ .../3.3.0-beta.3/building/concepts/index.mdx | 116 + .../building/concepts/industry-landscape.mdx | 141 + .../concepts/managing-response-size.mdx | 196 ++ .../building/concepts/protocol-comparison.mdx | 268 ++ .../building/concepts/security-model.mdx | 312 ++ .../cross-cutting/known-ambiguities.mdx | 80 + .../building/cross-cutting/sdk-stack.mdx | 340 ++ .../cross-cutting/version-adaptation.mdx | 314 ++ dist/docs/3.3.0-beta.3/building/index.mdx | 96 + .../building/operating/operating-an-agent.mdx | 128 + .../operating/orchestrator-design.mdx | 528 ++++ .../building/operating/seller-integration.mdx | 361 +++ .../operating/storyboard-troubleshooting.mdx | 150 + .../operating/support-recovery-runbooks.mdx | 233 ++ .../building/operating/transport-errors.mdx | 739 +++++ .../building/schemas-and-sdks.mdx | 96 + .../building/verification/aao-verified.mdx | 344 ++ .../verification/addie-socket-mode.mdx | 196 ++ .../verification/compliance-catalog.mdx | 407 +++ .../building/verification/conformance.mdx | 313 ++ .../building/verification/get-test-ready.mdx | 246 ++ .../building/verification/grading.mdx | 87 + .../verification/how-grading-works.mdx | 209 ++ .../verification/storyboards-vs-scenarios.mdx | 85 + .../validate-with-mock-fixtures.mdx | 138 + .../verification/validate-your-agent.mdx | 302 ++ .../3.3.0-beta.3/community/joining-slack.mdx | 40 + .../3.3.0-beta.3/community/working-group.mdx | 48 + .../contributing/creative33-sandbox.md | 98 + .../organization-deletion-containment.md | 289 ++ .../contributing/storyboard-authoring.md | 433 +++ .../contributing/testable-examples-demo.md | 87 + .../contributing/testable-snippets.md | 419 +++ .../contributing/x-entity-annotation.md | 150 + .../3.3.0-beta.3/creative/accessibility.mdx | 243 ++ .../creative/ai-creative-overview.mdx | 104 + .../3.3.0-beta.3/creative/asset-types.mdx | 729 +++++ .../creative/buyer-attached-inputs.mdx | 103 + .../creative/canonical-formats-migration.mdx | 122 + .../creative/canonical-formats.mdx | 1911 +++++++++++ .../3.3.0-beta.3/creative/catalog-schemas.mdx | 518 +++ dist/docs/3.3.0-beta.3/creative/catalogs.mdx | 1192 +++++++ .../3.3.0-beta.3/creative/channels/audio.mdx | 149 + .../creative/channels/broadcast.mdx | 617 ++++ .../creative/channels/carousels.mdx | 54 + .../3.3.0-beta.3/creative/channels/ctv.mdx | 35 + .../creative/channels/display.mdx | 210 ++ .../3.3.0-beta.3/creative/channels/dooh.mdx | 381 +++ .../3.3.0-beta.3/creative/channels/ooh.mdx | 50 + .../3.3.0-beta.3/creative/channels/print.mdx | 124 + .../3.3.0-beta.3/creative/channels/radio.mdx | 159 + .../creative/channels/social-native.mdx | 32 + .../3.3.0-beta.3/creative/channels/video.mdx | 146 + .../creative/creative-libraries.mdx | 396 +++ .../creative/creative-manifests.mdx | 142 + .../3.3.0-beta.3/creative/ctv-experiences.mdx | 140 + dist/docs/3.3.0-beta.3/creative/formats.mdx | 130 + .../creative/generative-creative.mdx | 138 + .../creative/implementing-creative-agents.mdx | 736 +++++ dist/docs/3.3.0-beta.3/creative/index.mdx | 179 ++ .../3.3.0-beta.3/creative/key-concepts.mdx | 196 ++ .../creative/multi-agent-orchestration.mdx | 134 + .../3.3.0-beta.3/creative/private-assets.mdx | 204 ++ .../docs/3.3.0-beta.3/creative/provenance.mdx | 663 ++++ .../published-post-reference-creatives.mdx | 168 + .../creative/representation-sets.mdx | 343 ++ .../sales-agent-creative-capabilities.mdx | 100 + .../3.3.0-beta.3/creative/specification.mdx | 825 +++++ .../sponsored-placement-adapter-contracts.mdx | 78 + .../task-reference/build_creative.mdx | 1480 +++++++++ .../task-reference/get_creative_delivery.mdx | 495 +++ .../task-reference/list_creative_formats.mdx | 78 + .../task-reference/list_creatives.mdx | 629 ++++ .../task-reference/list_transformers.mdx | 141 + .../preview_creative-advanced.mdx | 332 ++ .../task-reference/preview_creative.mdx | 516 +++ .../task-reference/sync_creatives.mdx | 1307 ++++++++ .../creative/template-format-ids.mdx | 65 + .../creative/tracker-execution-contracts.mdx | 204 ++ .../creative/universal-macros.mdx | 1102 +++++++ .../3.3.0-beta.3/curation/coming-soon.mdx | 41 + dist/docs/3.3.0-beta.3/faq.mdx | 403 +++ .../governance/annex-iii-obligations.mdx | 119 + .../governance/campaign/audit-trail.mdx | 330 ++ .../governance/campaign/index.mdx | 250 ++ .../governance/campaign/responsibilities.mdx | 89 + .../governance/campaign/safety-model.mdx | 143 + .../governance/campaign/specification.mdx | 1280 ++++++++ .../campaign/tasks/check_governance.mdx | 751 +++++ .../campaign/tasks/get_plan_audit_logs.mdx | 374 +++ .../governance/campaign/tasks/index.mdx | 23 + .../campaign/tasks/report_plan_adjustment.mdx | 144 + .../campaign/tasks/report_plan_outcome.mdx | 336 ++ .../governance/campaign/tasks/sync_plans.mdx | 297 ++ .../governance/collection/index.mdx | 188 ++ .../collection/tasks/collection_lists.mdx | 357 +++ .../content-standards/artifacts.mdx | 324 ++ .../implementation-guide.mdx | 424 +++ .../governance/content-standards/index.mdx | 428 +++ .../tasks/calibrate_content.mdx | 251 ++ .../tasks/create_content_standards.mdx | 127 + .../tasks/get_content_standards.mdx | 88 + .../tasks/get_media_buy_artifacts.mdx | 286 ++ .../tasks/list_content_standards.mdx | 71 + .../tasks/update_content_standards.mdx | 106 + .../tasks/validate_content_delivery.mdx | 185 ++ .../creative/get_creative_features.mdx | 276 ++ .../governance/creative/index.mdx | 208 ++ .../creative/policy-backed-rejections.mdx | 196 ++ .../creative/provenance-verification.mdx | 585 ++++ .../governance/embedded-human-judgment.mdx | 550 ++++ .../docs/3.3.0-beta.3/governance/overview.mdx | 310 ++ .../governance/policy-attribution.mdx | 163 + .../governance/policy-registry-sync.mdx | 113 + .../governance/policy-registry.mdx | 374 +++ .../governance/property/adagents.mdx | 1419 +++++++++ .../property/authorized-properties.mdx | 370 +++ .../governance/property/index.mdx | 292 ++ .../governance/property/managed-networks.mdx | 689 ++++ .../governance/property/specification.mdx | 646 ++++ .../governance/property/tasks/index.mdx | 151 + .../property/tasks/property_lists.mdx | 768 +++++ .../tasks/validate_property_delivery.mdx | 402 +++ .../3.3.0-beta.3/governance/rfc-process.mdx | 111 + .../governance/sell-side-governance.mdx | 72 + .../governance/working-group-charter.mdx | 92 + .../content-commerce-creative.png | Bin 0 -> 1410521 bytes .../guides/creator-economy/convergence.png | Bin 0 -> 1169867 bytes .../creator-economy/episode-products.png | Bin 0 -> 1357473 bytes .../play-button-progression.png | Bin 0 -> 1233029 bytes .../guides/creator-economy/three-stages.png | Bin 0 -> 1211679 bytes .../event-speakers-admin-modal.png | Bin 0 -> 74196 bytes dist/docs/3.3.0-beta.3/intro.mdx | 751 +++++ .../explaining-agentic-advertising.mdx | 75 + .../l1-agentic-advertising.mdx | 91 + .../l2-data-brand-governance.mdx | 91 + .../l3-deciding-and-leading.mdx | 129 + .../learning/decision-makers/overview.mdx | 102 + .../learning/failure-mode-scope.mdx | 218 ++ .../foundations/a1-agentic-advertising.mdx | 98 + .../foundations/a2-protocol-architecture.mdx | 101 + .../a2b-testing-your-first-agent.mdx | 291 ++ .../foundations/a3-ecosystem-governance.mdx | 113 + .../learning/instructional-design.mdx | 315 ++ dist/docs/3.3.0-beta.3/learning/overview.mdx | 133 + .../learning/policies/complaints.mdx | 65 + .../policies/conflict-of-interest.mdx | 39 + .../policies/intellectual-property.mdx | 43 + .../learning/policies/learner-records.mdx | 60 + .../learning/policies/nondiscrimination.mdx | 36 + .../policies/personnel-qualifications.mdx | 42 + .../learning/policies/recertification.mdx | 196 ++ .../3.3.0-beta.3/learning/policies/refund.mdx | 33 + .../learning/shared-account-change-feed.mdx | 189 ++ .../learning/specialist/brand.mdx | 200 ++ .../learning/specialist/creative.mdx | 221 ++ .../learning/specialist/governance.mdx | 194 ++ .../learning/specialist/media-buy.mdx | 433 +++ .../learning/specialist/security.mdx | 102 + .../learning/specialist/signals.mdx | 157 + .../specialist/sponsored-intelligence.mdx | 127 + .../buyer-briefs-and-get-products.mdx | 169 + .../supplements/creative-agent-workflows.mdx | 92 + .../governance-protocol-by-role.mdx | 85 + .../3.3.0-beta.3/learning/test-personas.md | 521 +++ .../3.3.0-beta.3/learning/tracks/buyer.mdx | 323 ++ .../3.3.0-beta.3/learning/tracks/platform.mdx | 232 ++ .../learning/tracks/publisher.mdx | 347 ++ .../3.3.0-beta.3/measurement/taxonomy.mdx | 245 ++ .../advanced-topics/accountability.mdx | 248 ++ .../advanced-topics/accounts-and-security.mdx | 63 + .../agentic-execution-engine.mdx | 425 +++ .../advanced-topics/billing-authority.mdx | 258 ++ .../media-buy/advanced-topics/index.mdx | 121 + .../advanced-topics/pricing-models.mdx | 1133 +++++++ .../media-buy/advanced-topics/sandbox.mdx | 253 ++ .../media-buy/advanced-topics/targeting.mdx | 1747 ++++++++++ .../implementing-standard-formats.mdx | 115 + .../media-buy/capability-discovery/index.mdx | 84 + .../3.3.0-beta.3/media-buy/commerce-media.mdx | 701 ++++ .../media-buy/conversion-tracking/index.mdx | 802 +++++ .../media-buy/creatives/index.mdx | 143 + dist/docs/3.3.0-beta.3/media-buy/index.mdx | 312 ++ .../media-buy/media-buys/index.mdx | 244 ++ .../media-buy/media-buys/indicators.mdx | 191 ++ .../media-buy/media-buys/lifecycle.mdx | 430 +++ .../media-buys/optimization-reporting.mdx | 1886 +++++++++++ .../media-buys/policy-compliance.mdx | 241 ++ .../product-discovery/brief-expectations.mdx | 188 ++ .../collections-and-installments.mdx | 1478 +++++++++ .../delegated-composition.mdx | 47 + .../product-discovery/example-briefs.mdx | 161 + .../media-buy/product-discovery/index.mdx | 251 ++ .../product-discovery/media-products.mdx | 2057 ++++++++++++ .../proposal-negotiation.mdx | 855 +++++ .../product-discovery/refinement.mdx | 523 +++ .../reporting-core-implementation-guide.mdx | 474 +++ .../media-buy/service-commitments.mdx | 168 + .../3.3.0-beta.3/media-buy/specification.mdx | 1313 ++++++++ .../task-reference/accept_proposal.mdx | 61 + .../media-buy/task-reference/buy_products.mdx | 77 + .../task-reference/control_media_buy.mdx | 124 + .../task-reference/create_media_buy.mdx | 1904 +++++++++++ .../task-reference/decline_proposals.mdx | 38 + .../task-reference/get_media_buy_delivery.mdx | 1127 +++++++ .../task-reference/get_media_buy_issue.mdx | 42 + .../task-reference/get_media_buys.mdx | 863 +++++ .../media-buy/task-reference/get_products.mdx | 2219 +++++++++++++ .../task-reference/get_reporting_status.mdx | 1027 ++++++ .../media-buy/task-reference/index.mdx | 199 ++ .../task-reference/list_collections.mdx | 320 ++ .../task-reference/list_products.mdx | 144 + .../task-reference/list_proposals.mdx | 116 + .../media-buy/task-reference/log_event.mdx | 566 ++++ .../provide_performance_feedback.mdx | 246 ++ .../task-reference/refine_proposals.mdx | 432 +++ .../task-reference/report_media_buy_issue.mdx | 97 + .../task-reference/request_proposals.mdx | 319 ++ .../task-reference/sync_audiences.mdx | 714 +++++ .../task-reference/sync_catalogs.mdx | 542 ++++ .../task-reference/sync_event_sources.mdx | 537 ++++ .../sync_reporting_receipts.mdx | 132 + .../task-reference/sync_reporting_status.mdx | 261 ++ .../task-reference/update_media_buy.mdx | 1057 +++++++ .../3.3.0-beta.3/protocol/architecture.mdx | 122 + .../protocol/calling-an-agent.mdx | 183 ++ .../protocol/capabilities-explorer.mdx | 251 ++ .../protocol/design-principles.mdx | 219 ++ .../protocol/format-references.mdx | 72 + .../protocol/get_adcp_capabilities.mdx | 2565 +++++++++++++++ .../3.3.0-beta.3/protocol/get_principal.mdx | 142 + .../protocol/language-and-localization.mdx | 114 + .../3.3.0-beta.3/protocol/required-tasks.mdx | 218 ++ .../protocol/snapshot-and-log.mdx | 211 ++ .../sync_agent_notification_configs.mdx | 135 + .../3.3.0-beta.3/protocol/sync_principal.mdx | 402 +++ dist/docs/3.3.0-beta.3/quickstart.mdx | 331 ++ dist/docs/3.3.0-beta.3/reference/3-2-beta.mdx | 74 + dist/docs/3.3.0-beta.3/reference/3-3-beta.mdx | 248 ++ .../docs/3.3.0-beta.3/reference/changelog.mdx | 12 + .../reference/experimental-status.mdx | 165 + dist/docs/3.3.0-beta.3/reference/glossary.mdx | 484 +++ .../3.3.0-beta.3/reference/gmsf-reference.mdx | 167 + .../reference/implementor-faq.mdx | 533 ++++ .../reference/known-limitations.mdx | 125 + .../reference/media-channel-taxonomy.mdx | 805 +++++ .../reference/migration/3-0-to-3-1.mdx | 113 + .../reference/migration/3-1-to-3-2.mdx | 842 +++++ .../reference/migration/3-2-to-3-3.mdx | 679 ++++ .../migration/agent-identity-3-3.mdx | 126 + .../reference/migration/asset-access.mdx | 126 + .../reference/migration/attribution.mdx | 104 + .../reference/migration/audiences.mdx | 136 + .../reference/migration/brand-identity.mdx | 125 + .../reference/migration/catalogs.mdx | 368 +++ .../reference/migration/channels.mdx | 197 ++ .../migration/creative-transformers.mdx | 90 + .../reference/migration/creatives.mdx | 419 +++ .../cross-role-governance-enforcement.mdx | 83 + .../reference/migration/geo-targeting.mdx | 210 ++ .../reference/migration/index.mdx | 195 ++ .../reference/migration/media-buy-status.mdx | 103 + .../migration/optimization-goals.mdx | 200 ++ .../migration/prerelease-upgrades.mdx | 228 ++ .../reference/migration/pricing.mdx | 227 ++ .../reliable-reporting-status-loop.mdx | 222 ++ .../reference/migration/signals.mdx | 177 ++ .../migration/targeting-aware-discovery.mdx | 371 +++ .../reference/migration/v3-readiness.mdx | 202 ++ .../reference/migration/v4-compatibility.mdx | 247 ++ .../reference/privacy-considerations.mdx | 125 + .../3.3.0-beta.3/reference/release-notes.mdx | 1952 ++++++++++++ dist/docs/3.3.0-beta.3/reference/roadmap.mdx | 156 + .../reference/schema-extensions.mdx | 162 + .../reference/specification-lifecycle.mdx | 124 + .../reference/test-vectors/index.mdx | 91 + .../3.3.0-beta.3/reference/trust-json.mdx | 138 + .../reference/url-canonicalization.mdx | 83 + .../docs/3.3.0-beta.3/reference/v2-sunset.mdx | 71 + .../reference/verifying-protocol-tarballs.mdx | 124 + .../3.3.0-beta.3/reference/versioning.mdx | 406 +++ dist/docs/3.3.0-beta.3/reference/versions.mdx | 140 + .../reference/whats-new-in-3-1.mdx | 353 +++ .../reference/whats-new-in-3-2.mdx | 240 ++ .../reference/whats-new-in-3-3.mdx | 265 ++ .../reference/whats-new-in-v3.mdx | 955 ++++++ dist/docs/3.3.0-beta.3/registry/index.mdx | 976 ++++++ .../registry/maintaining-your-agent.mdx | 165 + .../registry/registering-an-agent.mdx | 118 + .../runbooks/addie-gemini-direct.md | 349 ++ .../addie-matched-v4-private-authority.md | 315 ++ .../runbooks/addie-reliability-audit.md | 101 + .../hosted-compliance-bundle-advancement.md | 18 + .../runbooks/training-agent-gcs-reporting.md | 540 ++++ .../3.3.0-beta.3/signals/data-providers.mdx | 739 +++++ dist/docs/3.3.0-beta.3/signals/ecosystem.mdx | 525 +++ .../3.3.0-beta.3/signals/key-concepts.mdx | 158 + dist/docs/3.3.0-beta.3/signals/overview.mdx | 212 ++ .../3.3.0-beta.3/signals/specification.mdx | 270 ++ .../signals/tasks/activate_signal.mdx | 701 ++++ .../signals/tasks/get_signals.mdx | 1291 ++++++++ .../3.3.0-beta.3/slack-invite-recovery.js | 58 + .../snippets/compliance-error-codes.mdx | 1331 ++++++++ .../compliance-storyboard-inventory.mdx | 64 + dist/docs/3.3.0-beta.3/spec-guidelines.md | 479 +++ .../implementing-si-agents.mdx | 270 ++ .../implementing-si-hosts.mdx | 138 + .../sponsored-intelligence/measurement.mdx | 33 + .../sponsored-intelligence/monetizing-ai.mdx | 121 + .../sponsored-intelligence/networks.mdx | 197 ++ .../sponsored-intelligence/overview.mdx | 257 ++ .../product-spectrum.mdx | 163 + .../si-chat-protocol.mdx | 496 +++ .../sponsored-intelligence/specification.mdx | 574 ++++ .../sponsored-intelligence/tasks/index.mdx | 123 + .../tasks/si_get_offering.mdx | 408 +++ .../tasks/si_initiate_session.mdx | 239 ++ .../tasks/si_send_message.mdx | 224 ++ .../tasks/si_terminate_session.mdx | 288 ++ .../sponsored-intelligence/workflow.mdx | 213 ++ dist/docs/3.3.0-beta.3/trust.mdx | 125 + .../trusted-match/ai-mediation.mdx | 191 ++ .../trusted-match/buyer-guide.mdx | 250 ++ .../trusted-match/context-and-identity.mdx | 231 ++ .../trusted-match/data-protection-roles.mdx | 241 ++ .../trusted-match/execution-gap.mdx | 93 + .../identity-match-implementation.mdx | 117 + .../impression-tracker-implementation.mdx | 304 ++ .../docs/3.3.0-beta.3/trusted-match/index.mdx | 247 ++ .../trusted-match/migration-from-axe.mdx | 111 + .../trusted-match/privacy-architecture.mdx | 182 ++ .../trusted-match/router-architecture.mdx | 305 ++ .../trusted-match/specification.mdx | 891 ++++++ .../trusted-match/surfaces/ai-assistants.mdx | 178 ++ .../trusted-match/surfaces/ctv.mdx | 213 ++ .../trusted-match/surfaces/mobile.mdx | 273 ++ .../trusted-match/surfaces/retail-media.mdx | 125 + .../trusted-match/surfaces/web.mdx | 296 ++ .../3.3.0-beta.3/verification/overview.mdx | 258 ++ docs.json | 784 ++--- server/src/addie/mcp/schema-tools.ts | 2 +- 410 files changed, 141997 insertions(+), 393 deletions(-) create mode 100644 dist/docs/3.3.0-beta.3/aao/aao-admins.mdx create mode 100644 dist/docs/3.3.0-beta.3/aao/addie-tools.mdx create mode 100644 dist/docs/3.3.0-beta.3/aao/connect-addie.mdx create mode 100644 dist/docs/3.3.0-beta.3/aao/directory-api.mdx create mode 100644 dist/docs/3.3.0-beta.3/aao/org-admins.mdx create mode 100644 dist/docs/3.3.0-beta.3/aao/users.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/buyer-agent-self-onboarding.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/principal-registration-walkthrough.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/get_account_financials.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/report_usage.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/request_account.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts.mdx create mode 100644 dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance.mdx create mode 100644 dist/docs/3.3.0-beta.3/ai-disclosure.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/brand-json.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/building-a-brand-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/for-advertisers.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/for-rights-holders.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/key-concepts.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/protect-your-brand.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/seller-setup.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/search_brands.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/update_rights.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claims.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/ui-guidance.mdx create mode 100644 dist/docs/3.3.0-beta.3/brand-protocol/walkthrough-rights-licensing.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-guide.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-format.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-guide.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L0/schemas.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L1/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L1/production-security-profile.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L1/security.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L1/webhook-verifier-tuning.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L2/account-state.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L2/authentication.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L2/context-sessions.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L2/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/async-identity-and-convergence.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/async-operations.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/task-lifecycle.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L4/build-a-caller.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L4/build-an-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L4/choose-your-sdk.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L4/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/by-layer/L4/migrate-from-hand-rolled.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/adcp-vs-openrtb.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/how-agents-communicate.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/industry-landscape.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/managing-response-size.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/protocol-comparison.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/concepts/security-model.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/cross-cutting/known-ambiguities.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/cross-cutting/sdk-stack.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/cross-cutting/version-adaptation.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/operating/operating-an-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/operating/orchestrator-design.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/operating/seller-integration.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/operating/storyboard-troubleshooting.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/operating/support-recovery-runbooks.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/operating/transport-errors.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/schemas-and-sdks.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/aao-verified.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/addie-socket-mode.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/compliance-catalog.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/conformance.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/get-test-ready.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/grading.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/how-grading-works.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/storyboards-vs-scenarios.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/validate-with-mock-fixtures.mdx create mode 100644 dist/docs/3.3.0-beta.3/building/verification/validate-your-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/community/joining-slack.mdx create mode 100644 dist/docs/3.3.0-beta.3/community/working-group.mdx create mode 100644 dist/docs/3.3.0-beta.3/contributing/creative33-sandbox.md create mode 100644 dist/docs/3.3.0-beta.3/contributing/organization-deletion-containment.md create mode 100644 dist/docs/3.3.0-beta.3/contributing/storyboard-authoring.md create mode 100644 dist/docs/3.3.0-beta.3/contributing/testable-examples-demo.md create mode 100644 dist/docs/3.3.0-beta.3/contributing/testable-snippets.md create mode 100644 dist/docs/3.3.0-beta.3/contributing/x-entity-annotation.md create mode 100644 dist/docs/3.3.0-beta.3/creative/accessibility.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/ai-creative-overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/asset-types.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/buyer-attached-inputs.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/canonical-formats-migration.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/canonical-formats.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/catalog-schemas.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/catalogs.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/audio.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/broadcast.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/carousels.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/ctv.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/display.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/dooh.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/ooh.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/print.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/radio.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/social-native.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/channels/video.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/creative-libraries.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/creative-manifests.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/ctv-experiences.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/formats.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/generative-creative.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/implementing-creative-agents.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/key-concepts.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/multi-agent-orchestration.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/private-assets.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/provenance.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/published-post-reference-creatives.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/representation-sets.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/sales-agent-creative-capabilities.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/sponsored-placement-adapter-contracts.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/build_creative.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/list_creative_formats.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/list_transformers.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative-advanced.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/template-format-ids.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/tracker-execution-contracts.mdx create mode 100644 dist/docs/3.3.0-beta.3/creative/universal-macros.mdx create mode 100644 dist/docs/3.3.0-beta.3/curation/coming-soon.mdx create mode 100644 dist/docs/3.3.0-beta.3/faq.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/annex-iii-obligations.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/audit-trail.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/responsibilities.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/safety-model.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/tasks/check_governance.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/tasks/get_plan_audit_logs.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/tasks/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/tasks/report_plan_adjustment.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/tasks/report_plan_outcome.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/campaign/tasks/sync_plans.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/collection/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/collection/tasks/collection_lists.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/artifacts.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/implementation-guide.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/calibrate_content.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/create_content_standards.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/get_content_standards.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/get_media_buy_artifacts.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/list_content_standards.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/update_content_standards.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/content-standards/tasks/validate_content_delivery.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/creative/get_creative_features.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/creative/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/creative/policy-backed-rejections.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/creative/provenance-verification.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/embedded-human-judgment.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/policy-attribution.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/policy-registry-sync.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/policy-registry.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/adagents.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/authorized-properties.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/managed-networks.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/tasks/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/tasks/property_lists.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/property/tasks/validate_property_delivery.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/rfc-process.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/sell-side-governance.mdx create mode 100644 dist/docs/3.3.0-beta.3/governance/working-group-charter.mdx create mode 100644 dist/docs/3.3.0-beta.3/images/guides/creator-economy/content-commerce-creative.png create mode 100644 dist/docs/3.3.0-beta.3/images/guides/creator-economy/convergence.png create mode 100644 dist/docs/3.3.0-beta.3/images/guides/creator-economy/episode-products.png create mode 100644 dist/docs/3.3.0-beta.3/images/guides/creator-economy/play-button-progression.png create mode 100644 dist/docs/3.3.0-beta.3/images/guides/creator-economy/three-stages.png create mode 100644 dist/docs/3.3.0-beta.3/images/pr-screenshots/event-speakers-admin-modal.png create mode 100644 dist/docs/3.3.0-beta.3/intro.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/decision-makers/explaining-agentic-advertising.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/decision-makers/l1-agentic-advertising.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/decision-makers/l2-data-brand-governance.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/decision-makers/l3-deciding-and-leading.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/decision-makers/overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/failure-mode-scope.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/foundations/a1-agentic-advertising.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/foundations/a2-protocol-architecture.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/foundations/a2b-testing-your-first-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/foundations/a3-ecosystem-governance.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/instructional-design.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/complaints.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/conflict-of-interest.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/intellectual-property.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/learner-records.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/nondiscrimination.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/personnel-qualifications.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/recertification.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/policies/refund.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/shared-account-change-feed.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/brand.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/creative.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/governance.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/media-buy.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/security.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/signals.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/specialist/sponsored-intelligence.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/supplements/buyer-briefs-and-get-products.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/supplements/creative-agent-workflows.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/supplements/governance-protocol-by-role.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/test-personas.md create mode 100644 dist/docs/3.3.0-beta.3/learning/tracks/buyer.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/tracks/platform.mdx create mode 100644 dist/docs/3.3.0-beta.3/learning/tracks/publisher.mdx create mode 100644 dist/docs/3.3.0-beta.3/measurement/taxonomy.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/accountability.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/accounts-and-security.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/agentic-execution-engine.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/billing-authority.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/pricing-models.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/sandbox.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/advanced-topics/targeting.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/capability-discovery/implementing-standard-formats.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/capability-discovery/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/commerce-media.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/conversion-tracking/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/creatives/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/media-buys/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/media-buys/indicators.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/media-buys/lifecycle.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/media-buys/optimization-reporting.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/media-buys/policy-compliance.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/brief-expectations.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/collections-and-installments.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/delegated-composition.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/example-briefs.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/media-products.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/proposal-negotiation.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/product-discovery/refinement.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/reporting-core-implementation-guide.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/service-commitments.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/accept_proposal.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/buy_products.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/control_media_buy.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/decline_proposals.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buy_delivery.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buy_issue.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/get_reporting_status.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/list_collections.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/list_products.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/list_proposals.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/log_event.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/provide_performance_feedback.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/refine_proposals.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/report_media_buy_issue.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/request_proposals.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_audiences.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_catalogs.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_event_sources.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_reporting_receipts.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_reporting_status.mdx create mode 100644 dist/docs/3.3.0-beta.3/media-buy/task-reference/update_media_buy.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/architecture.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/calling-an-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/capabilities-explorer.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/design-principles.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/format-references.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/get_principal.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/language-and-localization.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/required-tasks.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/snapshot-and-log.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/sync_agent_notification_configs.mdx create mode 100644 dist/docs/3.3.0-beta.3/protocol/sync_principal.mdx create mode 100644 dist/docs/3.3.0-beta.3/quickstart.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/3-2-beta.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/3-3-beta.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/changelog.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/experimental-status.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/glossary.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/gmsf-reference.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/implementor-faq.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/known-limitations.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/media-channel-taxonomy.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/3-0-to-3-1.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/3-1-to-3-2.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/3-2-to-3-3.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/agent-identity-3-3.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/asset-access.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/attribution.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/audiences.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/brand-identity.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/catalogs.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/channels.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/creative-transformers.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/creatives.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/cross-role-governance-enforcement.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/geo-targeting.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/media-buy-status.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/optimization-goals.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/prerelease-upgrades.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/pricing.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/reliable-reporting-status-loop.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/signals.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/targeting-aware-discovery.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/v3-readiness.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/migration/v4-compatibility.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/privacy-considerations.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/release-notes.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/roadmap.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/schema-extensions.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/specification-lifecycle.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/test-vectors/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/trust-json.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/url-canonicalization.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/v2-sunset.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/verifying-protocol-tarballs.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/versioning.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/versions.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/whats-new-in-3-1.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/whats-new-in-3-2.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/whats-new-in-3-3.mdx create mode 100644 dist/docs/3.3.0-beta.3/reference/whats-new-in-v3.mdx create mode 100644 dist/docs/3.3.0-beta.3/registry/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/registry/maintaining-your-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/registry/registering-an-agent.mdx create mode 100644 dist/docs/3.3.0-beta.3/runbooks/addie-gemini-direct.md create mode 100644 dist/docs/3.3.0-beta.3/runbooks/addie-matched-v4-private-authority.md create mode 100644 dist/docs/3.3.0-beta.3/runbooks/addie-reliability-audit.md create mode 100644 dist/docs/3.3.0-beta.3/runbooks/hosted-compliance-bundle-advancement.md create mode 100644 dist/docs/3.3.0-beta.3/runbooks/training-agent-gcs-reporting.md create mode 100644 dist/docs/3.3.0-beta.3/signals/data-providers.mdx create mode 100644 dist/docs/3.3.0-beta.3/signals/ecosystem.mdx create mode 100644 dist/docs/3.3.0-beta.3/signals/key-concepts.mdx create mode 100644 dist/docs/3.3.0-beta.3/signals/overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/signals/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/signals/tasks/activate_signal.mdx create mode 100644 dist/docs/3.3.0-beta.3/signals/tasks/get_signals.mdx create mode 100644 dist/docs/3.3.0-beta.3/slack-invite-recovery.js create mode 100644 dist/docs/3.3.0-beta.3/snippets/compliance-error-codes.mdx create mode 100644 dist/docs/3.3.0-beta.3/snippets/compliance-storyboard-inventory.mdx create mode 100644 dist/docs/3.3.0-beta.3/spec-guidelines.md create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/implementing-si-agents.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/implementing-si-hosts.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/measurement.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/monetizing-ai.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/networks.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/overview.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/product-spectrum.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/si-chat-protocol.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/tasks/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/tasks/si_get_offering.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/tasks/si_initiate_session.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/tasks/si_send_message.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/tasks/si_terminate_session.mdx create mode 100644 dist/docs/3.3.0-beta.3/sponsored-intelligence/workflow.mdx create mode 100644 dist/docs/3.3.0-beta.3/trust.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/ai-mediation.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/buyer-guide.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/context-and-identity.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/data-protection-roles.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/execution-gap.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/identity-match-implementation.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/impression-tracker-implementation.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/index.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/migration-from-axe.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/privacy-architecture.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/router-architecture.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/specification.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/surfaces/ai-assistants.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/surfaces/ctv.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/surfaces/mobile.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/surfaces/retail-media.mdx create mode 100644 dist/docs/3.3.0-beta.3/trusted-match/surfaces/web.mdx create mode 100644 dist/docs/3.3.0-beta.3/verification/overview.mdx diff --git a/.dockerignore b/.dockerignore index c1546442ee3..643827bd8d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,6 +39,8 @@ dist/docs/* !dist/docs/3.3.0-beta.2/** !dist/docs/3.1.31 !dist/docs/3.1.31/** +!dist/docs/3.3.0-beta.3 +!dist/docs/3.3.0-beta.3/** !dist/schemas dist/schemas/latest dist/schemas/v* diff --git a/dist/docs/3.3.0-beta.3/aao/aao-admins.mdx b/dist/docs/3.3.0-beta.3/aao/aao-admins.mdx new file mode 100644 index 00000000000..324df74ff49 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/aao/aao-admins.mdx @@ -0,0 +1,84 @@ +--- +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.3.0-beta.3/aao/users). For org-admin docs see [AAO for Org Admins](/dist/docs/3.3.0-beta.3/aao/org-admins). For the full list of Addie's tools see the [Addie Tool Reference](/dist/docs/3.3.0-beta.3/aao/addie-tools). + +## Becoming an AAO admin + +Site-admin access is granted through membership in the server-pinned `aao-admin` working group. Manage that membership from the existing people/working-group administration views; WorkOS organization roles never grant AgenticAdvertising.org platform administration. + +`ADMIN_EMAILS` is an environment-managed break-glass path. It is not shown or edited in the UI. Keep its list minimal, rotate it through the deployment-secret process, and remove emergency entries as soon as the incident is resolved. When removing an entry, deploy the changed environment to every application replica and verify that the former address can no longer use an admin route. + +Addie web and voice administrator checks use the WorkOS credential that actually signed in, including its own email for break-glass access. Linking identities does not transfer platform administration: an administrator signing in through a linked non-admin canonical profile retains access, while a non-admin credential linked to an administrator does not inherit it. Organization owners cannot list or resolve the platform-wide escalation queue. + +When the administrator lookup is unavailable, Addie web requests return a retryable `503` with `admin_authorization_unavailable`; an already-open Addie stream reports a recoverable error. This is separate from `403` for a confirmed denial. A separately configured break-glass email remains valid during a membership lookup outage. Voice calls require captured credential provenance; legacy calls must restart through sign-in. + +Voice callbacks also require a signed session capability bound to the server's conversation record. A thread ID supplied in conversation text grants no access. Ending a call revokes that capability before contacting the video provider; expired, revoked, or unverified calls require a new session. Rotate `TAVUS_LLM_SECRET` consistently across replicas; rotation invalidates existing call capabilities. + +Every site-admin grant or revoke requires a written reason and writes an append-only event with the actor, target, access mechanism, reason, and timestamp. The revoke and its audit event commit together, so deleting the active membership cannot delete its forensic record. + +Admin authorization results are not cached. A grant or revoke is visible to every replica on its next authorization check, without a positive-cache propagation window. Failed checks remain fail-closed and are reported as unavailable rather than reused as either a grant or denial. + +## 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) + +Addie's committee-leader review powers also belong to the authenticated credential. Linking to a committee leader's profile does not grant publishing, approval, rejection, revision, or pending-review access. Personal author attribution remains associated with the person's profile. + +- **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. + +## Portrait management + +Members can manage their own illustrated portrait from their profile page. Addie's member-scoped portrait tools (`check_portrait_status`, `offer_portrait_generation`) only operate on the caller's own member organization context. + +Admin moderation uses the admin portrait API: + +- **List portraits.** `GET /api/admin/portraits?status=generated` returns generated portraits for review. Use `status=approved` or omit `status` to inspect other queues. +- **Map active portraits.** `GET /api/admin/portraits/map` returns the lightweight user-to-portrait mapping for directory checks. +- **Remove a portrait.** `DELETE /api/admin/portraits/:id` rejects the portrait and clears it from the member if it is their active portrait. + +## 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, environment-managed break-glass access. Rotate and remove entries through deployment secrets; do not treat it as a UI-managed role. +- `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.3.0-beta.3/aao/addie-tools.mdx b/dist/docs/3.3.0-beta.3/aao/addie-tools.mdx new file mode 100644 index 00000000000..d4c6a57abe9 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/aao/addie-tools.mdx @@ -0,0 +1,2577 @@ +--- +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 AdCP documentation and indexed ad tech specifications for protocol questions and implementation help + +### `search_docs` + +Search official AdCP docs, extracted schema facts, and working group documents. Protocol results are isolated by release and always name their version. Pass version when the user names one; omission means stable, never beta. Website and working group results are version-independent. Use one specific query. Verify exact fields and enums with get_schema at the same version, use list_schemas for schema availability, and use get_doc for full content. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `get_doc` + +Get the full content of a specific documentation page by ID. Canonical IDs returned by search_docs already include the version. For a legacy unversioned ID, pass version; omitted version means stable default. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +### `search_repos` + +Search indexed external GitHub repositories for ad tech specifications and protocols. Includes: AdCP (core protocol spec, sales agent, signals agent, JS/Python/Go clients), A2A (Google Agent-to-Agent protocol and samples), MCP (Model Context Protocol spec, TypeScript/Python SDKs, reference servers), IAB Tech Lab specs (OpenRTB 2.x/3.0, AdCOM, OpenDirect, ARTF, UCP, GPP, TCF, US Privacy, UID2, VAST, ads.cert), Prebid (full documentation site with configuration guides, bidder adapter docs, ad ops workflows, GAM integration, troubleshooting, Prebid Mobile, and video; plus Prebid.js source with module docs, and Prebid Server source), and LangGraph. Use this for protocol details, spec comparisons, implementation examples, and SDK usage. + +*Source: `server/src/addie/mcp/knowledge-search.ts`* + +## community_discussions + +Search Slack history and channel activity or read files shared in Slack when the user explicitly requests community discussion research + +### `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`* + +### `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`* + +## industry_research + +Search curated industry resources and recent news or read supplied public web pages + +### `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`* + +## schema_reference + +Inspect, compare, and validate JSON against versioned AdCP schemas + +### `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_personal_profile + +Show or update the current member's personal profile and account details + +### `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. Does NOT update first/last name — use set_my_name for that. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## member_company_profile + +Manage the current member's company directory listing, logo, brand color, and organization brand-domain claim + +### `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 selected 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. If the caller belongs to multiple organizations, or the current org is a personal workspace while the domain matches a company org, STOP and ask them to choose the company organization_id before issuing a challenge. 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, org_selection_required, 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 for the selected organization 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, org_selection_required, 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`* + +## community_group_discovery + +Browse working groups and councils, inspect one group, review personal memberships, and read committee documents + +### `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`* + +### `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`* + +### `list_committee_documents` + +List documents tracked by a committee. Shows document titles, status, and summaries. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## community_group_membership + +Inspect a working group, join a public group, or request an invitation to a private group + +### `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`* + +## council_interest + +Browse councils and manage the current member's future-council interest signups + +### `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`* + +### `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`* + +## community_group_contribution + +Review the current member's working groups, create a post, or save a community resource + +### `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`* + +### `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`* + +### `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`* + +## community_group_full_participation + +Handle one long request across at least three group participation workflows + +### `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`* + +### `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`* + +### `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`* + +## partner_directory + +Search member organizations for partners, vendors, consultants, and service providers, inspect member profiles, request introductions, and review search analytics + +### `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 visible AgenticAdvertising.org member organizations. Public profiles are always returned; API-tier callers also see organizations with members_only agents. Filter by offerings, markets, or search. Returns visibility-filtered agent details plus public/members-only registration counts; private registrations are excluded. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `get_member` + +Get an AgenticAdvertising.org member by slug. Returns visibility-filtered agent details plus public/members-only registration counts; private registrations are excluded. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +## agent_publisher_directory + +Browse visible AdCP agents and publishers, inspect an agent by URL, and find agents authorized or claimed for a publisher domain + +### `list_agents` + +List AdCP agents visible to the caller. Public agents are always returned; callers on an API-access tier also receive members_only agents. Private agents are never returned. Can filter by agent type. + +*Source: `server/src/addie/mcp/directory-tools.ts`* + +### `get_agent` + +Get details for a specific agent by URL when it is visible to the caller. Public agents are visible to everyone; members_only agents require an API-access tier; private agents are not returned. + +*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`* + +## brand_registry_records + +Research, resolve, save, and browse brand-registry records and identify missing brands + +### `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 and cached relationship trust state. 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`* + +## brand_registry_identity + +Manage brand logos and canonical documents, verify reciprocal brand.json assertions, and notify pending verification + +### `upload_brand_logo` + +Fetch and promote a logo file for a brand in the registry. The upload queues for moderator review (community contribution) and, when approved, receives an AAO-hosted public /assets/brands URL suitable for brand.json. Blocked when a brand has a verified DNS owner — only that org can change its logo, and the human must use the brand-builder UI for owner-attested uploads. + +*Source: `server/src/addie/mcp/brand-tools.ts`* + +### `publish_brand_canonical_document` + +Generate a Brand Canonical Document (brand.json variant 5) for a sub-brand team. Builds a JSON document with the brand's identity fields (logos/colors/tone/etc.), validates it against the brand.json schema, and returns the document plus the path where it should be hosted (https://{domain}/.well-known/brand.json). Returns validation errors instead of an invalid document. Does NOT upload — the operator hosts the returned JSON themselves. + +*Source: `server/src/addie/mcp/brand-canonical-tools.ts`* + +### `add_to_brand_refs` + +Append a portfolio_entry pointer to a House Portfolio's brand_refs[]. Either pass the house's brand.json directly (house_brand_json) or have the tool fetch it (house_domain). Enforces the cross-array uniqueness invariants from the spec: a brand_id MUST NOT appear in both brands[] and brand_refs[], and each brand_id/domain MUST be unique within brand_refs[]. Returns the updated brand.json or a clear validation error. + +*Source: `server/src/addie/mcp/brand-canonical-tools.ts`* + +### `check_mutual_assertion` + +Given a leaf brand's domain, fetch its canonical document and its claimed house's portfolio (following House Redirects on the house side, up to 3 hops, per the Conformance section). Classify the relationship trust into: mutual (both sides reciprocate), leaf_only (leaf claims house, house silent), house_only (house claims leaf, leaf silent — returned by checking the house if a different caller already has it), standalone (no house_domain), or unverifiable (one or both fetches failed). Returns the house's contact.email when present so the caller can pass it into notify_pending_verification on leaf_only edges. + +*Source: `server/src/addie/mcp/brand-canonical-tools.ts`* + +### `notify_pending_verification` + +Send the SHOULD-level notification email to the house's contact.email when a leaf_only edge is detected. Rate-limited to one notification per {leaf, house} pair per 24 hours, persisted in the brand_assertion_notifications table so concurrent callers can't double-send. Behind a feature flag (BRAND_ASSERTION_EMAIL_ENABLED) — defaults to log-only so operators can review the would-have-sent payload before enabling the live send path. + +*Source: `server/src/addie/mcp/brand-canonical-tools.ts`* + +## agent_registry + +Validate publisher and agent registry configuration, brand resolution, authorization, and cached agent status. + +### `validate_adagents` + +Validate a domain's /.well-known/adagents.json file with a live fetch. 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`* + +### `get_agent_status` + +Return the AAO registry's current status for an agent: health (online / last checked), declared capabilities, and the most recent compliance verdict per track from the comply storyboard suite. Reads cached state — does NOT perform a live probe. For agents not in the registry, returns guidance to register the agent (so the heartbeat picks it up) or to run evaluate_agent_quality for an on-demand check. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `check_publisher_authorization` + +Check if a publisher domain has authorized a specific agent. Results are cached briefly; pass force_refresh: true after the publisher changes adagents.json to bypass the cache. + +*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`* + +## agent_quality + +Evaluate an agent's live quality and buyer behavior, including protocol quality, RFP responses, and IO execution. + +### `evaluate_agent_quality` + +Evaluate an AdCP agent across its supported compliance tracks. Identical active requests coalesce across replicas and return running status. Partial receipts retain coverage/findings but cannot support definitive verdict or root-cause claims. Custom authenticated agents require save_agent first. + +*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`* + +## agent_authentication + +Diagnose an agent's public OAuth setup or grade RFC 9421 request-signing behavior. + +### `grade_agent_signing` + +Run the safe-default RFC 9421 request-signing conformance grader against a public HTTPS 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). Hosted Addie never probes private networks and always skips replay and rate-abuse live-side-effect vectors. Run the SDK CLI locally when testing trusted development endpoints or explicitly enabling live-side-effect vectors. + +*Source: `server/src/addie/mcp/auth-grader-tools.ts`* + +### `diagnose_agent_auth` + +Diagnose a public HTTPS 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. Hosted Addie never probes private networks; use the SDK CLI locally for trusted development endpoints. + +*Source: `server/src/addie/mcp/auth-grader-tools.ts`* + +## agent_end_to_end + +Run an end-to-end agent diagnosis across registry configuration, public authentication, and live RFP or IO behavior. + +### `validate_adagents` + +Validate a domain's /.well-known/adagents.json file with a live fetch. 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`* + +### `get_agent_status` + +Return the AAO registry's current status for an agent: health (online / last checked), declared capabilities, and the most recent compliance verdict per track from the comply storyboard suite. Reads cached state — does NOT perform a live probe. For agents not in the registry, returns guidance to register the agent (so the heartbeat picks it up) or to run evaluate_agent_quality for an on-demand check. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `check_publisher_authorization` + +Check if a publisher domain has authorized a specific agent. Results are cached briefly; pass force_refresh: true after the publisher changes adagents.json to bypass the cache. + +*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`* + +### `evaluate_agent_quality` + +Evaluate an AdCP agent across its supported compliance tracks. Identical active requests coalesce across replicas and return running status. Partial receipts retain coverage/findings but cannot support definitive verdict or root-cause claims. Custom authenticated agents require save_agent first. + +*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`* + +### `grade_agent_signing` + +Run the safe-default RFC 9421 request-signing conformance grader against a public HTTPS 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). Hosted Addie never probes private networks and always skips replay and rate-abuse live-side-effect vectors. Run the SDK CLI locally when testing trusted development endpoints or explicitly enabling live-side-effect vectors. + +*Source: `server/src/addie/mcp/auth-grader-tools.ts`* + +### `diagnose_agent_auth` + +Diagnose a public HTTPS 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. Hosted Addie never probes private networks; use the SDK CLI locally for trusted development endpoints. + +*Source: `server/src/addie/mcp/auth-grader-tools.ts`* + +## property_registry_records + +Resolve, save, and browse publisher property-registry records, including missing domains and property visibility. + +### `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`* + +## property_list_enrichment + +Audit supplied publisher-domain lists and assess unknown domains for property-registry review. + +### `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`* + +## property_identifier_catalog + +Resolve identifiers to stable property records, browse the property catalog, and dispute incorrect catalog entries. + +### `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`* + +## agent_storyboards + +Discover and run compliance storyboards against a supplied or previously registered agent URL, including follow-up requests to test it. Uses the agent's HTTP/MCP endpoint; no Socket Mode connection is needed. + +### `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`* + +## agent_conformance + +Run AdCP compliance storyboards over an explicitly requested Socket Mode connection: an outbound WebSocket from the user's private/local MCP server to Addie. Requires an organization and a connected conformance client. For testing a supplied or registered agent URL, use agent_storyboards instead. + +### `issue_conformance_token` + +Issue a short-lived JWT (1h TTL) the adopter pastes into their `@adcp/sdk/server` ConformanceClient config to connect their dev/staging MCP server outbound to Addie's conformance channel. The token is bound to the caller's WorkOS organization. Use this when the user is building an AdCP agent and wants Addie to run conformance/compliance tests against their dev environment without exposing it publicly. Returns the token, the WebSocket URL, and the expiry time. + +*Source: `server/src/addie/mcp/conformance-tools.ts`* + +### `run_conformance_against_my_agent` + +Run a compliance storyboard against the adopter MCP server connected to this Addie session via Socket Mode. The adopter must have started `@adcp/sdk/server` ConformanceClient with a token issued in this same chat session — the channel routes by WorkOS organization id. Returns a markdown report with phase/step pass/fail/skipped status, trimmed error text, and sanitized failed-validation details such as id, expected, and actual on failures. Use this after the user confirms their conformance client shows `status=connected`. + +*Source: `server/src/addie/mcp/conformance-tools.ts`* + +## adcp_task_operations + +Inspect or execute AdCP protocol tasks and check live agent capabilities. + +### `ask_about_adcp_task` + +Search AdCP protocol documentation for task parameters, workflows, concepts, or buyer rules. Call this BEFORE call_adcp_task when you need full parameter shapes for an uncommon task, or when an adcp_error response leaves you unsure how to recover. + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +### `call_adcp_task` + +Execute any registered AdCP protocol task against an agent. For uncommon tasks or when unsure about parameters, call ask_about_adcp_task first. +Does NOT include capability discovery — use the `get_adcp_capabilities` tool directly for that (it is not a task). + +Two rules a search round-trip cannot rescue you from after a mutating call: +• idempotency_key: REQUIRED on every mutating task (UUID). Same key on retry replays the same response. Generating a fresh UUID after a failed attempt is how you double-book. +• On adcp_error: read issues[].variants[] before retrying. It lists the exact valid shape — do not re-guess. + +Full buyer rules: ask_about_adcp_task with area="buyer". + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +### `call_adcp_get_products` + +Execute get_products with a machine-checkable request shape. +idempotency_key is required by this adapter and must remain identical when correcting or retrying the same logical request. +For buying_mode="brief", provide brief and omit refine. For "wholesale", omit both brief and refine. For "refine", provide refine and omit brief. + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +### `get_adcp_capabilities` + +Discover an agent's AdCP protocol support and capabilities. Returns supported tasks, domains, features, and configuration. + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +## adcp_agent_management + +Register, list, remove, or set up saved AdCP agents. + +### `save_agent` + +Register an agent for the current organization or an explicitly selected active organization (organization_id / organization_name). Appears in /dashboard/agents, initially members_only: visible to paying AgenticAdvertising.org members (Professional, Builder, Member, Leader). Public visibility is a separate dashboard action requiring one of those tiers and a primary brand domain. Require the owner’s declared type and auth-mode choice; never guess type. Capability probes establish verified type separately. Auth: none, encrypted bearer/basic, or OAuth client credentials. Prefer configure_auth_in_dashboard for secure credential entry outside chat. Interactive OAuth uses the agent card’s Authorize flow after saving without credentials. health_check_url provides fallback liveness for authenticated or path-prefixed endpoints; it does not establish capability or compliance. Follow the registration intake rules. + +*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`* + +## sponsored_intelligence_discovery + +Discover and connect to member brand agents that support Sponsored Intelligence, including anonymous offer-availability checks + +### `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`* + +## sponsored_intelligence_session + +Relay messages to, inspect, or end an active Sponsored Intelligence brand-agent session + +### `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`* + +## 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`* + +## publishing_submission + +Submit or inspect member perspectives, including Google Doc import + +### `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`* + +### `read_google_doc` + +Read a Google Doc, Sheet, Slide deck, or file from Google Drive. Returns a JSON object: `{ "status": "ok" | "access_denied" | "empty" | "invalid_input" | "unsupported_type" | "error", "title": string | null, "body": string | null, "format": "markdown" | "csv" | "text" | null, "mime_type": string | null, "message": string | null, "truncated": boolean }`. Branch on `status` — do not try to sniff error text out of `body`. On `ok`, Google Docs return markdown ready to pass straight to `propose_content`'s `content` field; pair with `title`. On `access_denied`, relay `message` (asks the user to share with ${...}). + +*Source: `server/src/addie/mcp/google-docs.ts`* + +## publishing_assets + +Manage published-content assets and cover illustrations + +### `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`* + +### `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`* + +### `generate_perspective_illustration` + +Generate an editorial illustration for a perspective article. The author describes what they want to see and the system generates a magazine-style illustration in the amber editorial palette. The author's portrait is automatically composited onto the final card. + +*Source: `server/src/addie/mcp/illustration-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`* + +## publishing_review + +Review pending member content and approve, reject, or request revisions when the current member leads that collection or is an admin + +### `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` + +Permanently reject content. Only committee leads (for their committees) and admins can reject content. The proposer will see the rejection reason. Use request_revisions instead if the author should address feedback and resubmit. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `request_revisions` + +Ask the author to revise and resubmit content. The article stays visible in the review queue as "needs revisions" until the author resubmits. Use this instead of reject_content when the content is fixable. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## publishing_promotion + +Browse published community perspectives and draft social posts promoting published content + +### `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`* + +### `draft_social_posts` + +Draft social media posts for the member based on an article or topic. In 'suggest' mode, find recent articles worth posting about and pitch them with member-specific angles. In 'draft' mode, generate 2 LinkedIn options and 1 X/Twitter option personalized to the member's company, role, and expertise. Use when a member asks for help writing a social post, or asks what they should be posting about. Do NOT use for general social media strategy advice or questions about social platforms. + +*Source: `server/src/addie/mcp/social-draft-tools.ts`* + +## github + +Read a specific GitHub issue or pull request, draft a bug report or feature request, and create a confirmed issue + +### `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 / contents of a specific RFC, epic, or PR. Returns title, body, state, labels, author, and optionally recent comments and the PR diff. Works on any public GitHub repo (forks included). PR review-thread comments (on specific diff lines) are NOT returned — only issue-style top-level comments. When the user pastes a PR link or asks for a code review, set `include_diff: true`. Do NOT use for keyword search — use list_github_issues. Do NOT use fetch_url on github.com/.../issues|pull URLs; this tool returns structured fields. + +*Source: `server/src/addie/mcp/member-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. Works on any public GitHub repo. Do NOT use when the user has a specific issue number — use get_github_issue. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## illustrations + +Search the approved illustration library when a user requests a diagram or a substantive explanation would materially benefit from a visual + +### `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`* + +## member_billing + +Handle the current member's own billing - find membership pricing, create a payment link, preview and confirm an invoice, or open the organization's billing portal + +### `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`* + +### `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 an organization owner or admin can view invoices, download receipts, update payment methods, and manage the subscription. +Use this when an owner or admin asks about receipts, invoices, billing history, payment methods, or subscription management. +The user must be signed in and have an active owner or admin role in the selected organization. + +*Source: `server/src/addie/mcp/billing-tools.ts`* + +## admin_billing_payments (admin only) + +Administer payment requests and invoices for other organizations, including listing pending invoices and resending an open invoice (admin only) + +### `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`* + +### `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`* + +### `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`* + +## admin_billing_discounts (admin only) + +Inspect, grant, or remove organization discounts and create promotion codes (admin only) + +### `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`* + +## admin_billing_account (admin only) + +Inspect an organization account, update its billing email, or preview and confirm a Stripe customer relink (admin only) + +### `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`* + +### `preview_org_stripe_customer_update` + +Preview relinking an organization to a different Stripe customer. This validates the org and Stripe customer, shows the current and proposed customer IDs, and returns a preview_token. It does not write any changes. Always show the preview to the admin before confirming. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `confirm_org_stripe_customer_update` + +Confirm a previously previewed organization Stripe customer relink. This tool does not accept a raw Stripe customer ID; it only commits the validated preview_token after explicit admin approval. + +*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. If no org matches, falls back to the users table — surfaces people who signed up with a matching email domain but have not yet created/joined an org (typical for inbound website signups). + +*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`* + +## meeting_attendance + +Check meeting agendas, RSVP, and manage attendance + +### `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`* + +### `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`* + +## meeting_scheduling + +Schedule, update, or cancel one meeting + +### `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 (RFC 3339 with an explicit offset) +Optional: working_group_slug (auto-detected from channel), description, agenda, duration_minutes, timezone, topic_slugs, recurrence + +IMPORTANT: The numeric offset in start_time must agree with timezone at that instant. For example, 2pm New York in January is "2026-01-15T14:00:00-05:00" with timezone "America/New_York". This prevents daylight-saving and server-timezone ambiguity. + +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`* + +### `cancel_meeting` + +Cancel a scheduled meeting. Sends cancellation notices to all attendees. + +*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: start_time must be RFC 3339 with an explicit offset that agrees with the IANA timezone. + +*Source: `server/src/addie/mcp/meeting-tools.ts`* + +## meeting_series_topics + +Manage recurring series and invitation topics + +### `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`* + +### `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_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`* + +## meeting_full_administration + +Handle one long request across meeting scheduling, attendance, and series or topics + +### `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 (RFC 3339 with an explicit offset) +Optional: working_group_slug (auto-detected from channel), description, agenda, duration_minutes, timezone, topic_slugs, recurrence + +IMPORTANT: The numeric offset in start_time must agree with timezone at that instant. For example, 2pm New York in January is "2026-01-15T14:00:00-05:00" with timezone "America/New_York". This prevents daylight-saving and server-timezone ambiguity. + +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: start_time must be RFC 3339 with an explicit offset that agrees with the IANA timezone. + +*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_co_leaders + +List, add, or remove co-leaders for a committee led by the current member + +### `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`* + +## committee_event_planning + +Create or update an event for a committee led by the current member + +### `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`* + +### `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 (RFC 3339 with an explicit offset), 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`* + +## committee_event_registrations + +Review or manage registrations, check attendance status, and invite people to a committee event + +### `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`* + +### `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`* + +## committee_full_leadership + +Handle one long request spanning committee co-leaders, event planning, and registration or invitation management + +### `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`* + +### `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 (RFC 3339 with an explicit offset), 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`* + +## admin_events (admin only) + +Admin mutation companion for the events set: create or update events, manage registrations, check a person's status, and send invitations; always select together with events (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 (RFC 3339 with an explicit offset), 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`* + +## admin_prospect_pipeline (admin only) + +Add, update, query, or claim prospect records (admin only) + +### `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`* + +### `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`* + +### `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`* + +## admin_prospect_research (admin only) + +Research, enrich, triage, or suggest prospect organizations (admin only) + +### `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`* + +### `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`* + +### `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`* + +## admin_feed_monitoring (admin only) + +Search industry feeds, inspect feed statistics, or list proposed feeds (admin only) + +### `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`* + +### `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`* + +## admin_feed_curation (admin only) + +Add industry feeds, review feed proposals, or add verified media contacts (admin only) + +### `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`* + +### `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`* + +## admin_group_structure (admin only) + +Create or list chapters and industry gatherings, and rename working groups (admin only) + +### `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`* + +### `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`* + +## admin_group_leadership (admin only) + +List working groups and manage their committee leaders (admin only) + +### `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`* + +## admin_group_membership (admin only) + +List working groups and add or remove working-group members (admin only) + +### `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_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`* + +## admin_organization_integrity (admin only) + +Diagnose and reconcile duplicate member organizations and their verified domains (admin only) + +### `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, list, set primary, or reconcile verified domains for an organization. Syncs to WorkOS. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +## admin_organization_member_records (admin only) + +Count or list paying memberships, inspect membership breakdowns, and manage organization-member roles, Slack rosters, and directory profiles (admin only) + +### `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`* + +### `list_slack_users_by_org` + +List Slack users from a specific organization. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_paying_members` + +List a limited page of paying memberships grouped by subscription level ($50K ICL, $10K corporate, $2.5K SMB, individual), with names and primary contacts. The summary counts all matching memberships before the row limit. Includes individuals by default; include_individual: false selects organizations only. For a count or membership breakdown without contact details, use query_admin_analytics with view=platform_stats. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `query_admin_analytics` + +Query live, read-only administrative analytics: platform totals, member-search performance, organizations ranked by user count, or people ranked by engagement. For current paying-member counts, use view="platform_stats" and report memberships.active: the exact, uncapped count of active, uncanceled AgenticAdvertising.org memberships. Use memberships.active_by_type for the individual/organization split and memberships.active_by_membership_tier for subscription tiers; these breakdowns use the same active population and each sums to memberships.active. This counts memberships, not people. organizations.by_type and organizations.by_membership_tier cover ALL records, including non-paying records; never use them to break down the paying total. Prefer this aggregate tool for counts and breakdowns; use list_paying_members for names and contacts. + +*Source: `server/src/addie/mcp/admin-analytics.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`* + +## admin_escalations (admin only) + +Inspect, list, and resolve escalations or pending support requests, including follow-ups referring to an escalation number (admin only, private conversations). + +### `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 (default: open), with total_count and next_offset. Use escalation_id for full details at any status. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +## admin_conversation_review (admin only) + +Query community analytics or review flagged conversations (admin only) + +### `query_admin_analytics` + +Query live, read-only administrative analytics: platform totals, member-search performance, organizations ranked by user count, or people ranked by engagement. For current paying-member counts, use view="platform_stats" and report memberships.active: the exact, uncapped count of active, uncanceled AgenticAdvertising.org memberships. Use memberships.active_by_type for the individual/organization split and memberships.active_by_membership_tier for subscription tiers; these breakdowns use the same active population and each sums to memberships.active. This counts memberships, not people. organizations.by_type and organizations.by_membership_tier cover ALL records, including non-paying records; never use them to break down the paying total. Prefer this aggregate tool for counts and breakdowns; use list_paying_members for names and contacts. + +*Source: `server/src/addie/mcp/admin-analytics.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`* + +## admin_followup_tasks (admin only) + +Manage reminders and tasks or log member and prospect interactions (admin only) + +### `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`* + +## admin_brand_registry_integrity (admin only) + +Diagnose brand/property registry gaps and reconcile mirrors, orphaned brands, and ownership (admin only) + +### `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`* + +### `list_pending_community_mirrors` + +List community mirror proposals awaiting expert review, including platform, proposal ID, digest, and submission time. + +*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`* + +## admin_brand_logo_review (admin only) + +List and moderate pending or existing brand-logo submissions (admin only) + +### `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`* + +## outreach_reporting (admin only) + +Outreach performance, history, and 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`* + +### `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`* + +## outreach_contact_management (admin only) + +Person/account lookup, contact creation, and outreach delivery (admin only) + +### `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_account` + +Get complete account view for any organization: lifecycle stage, membership status, engagement metrics, pipeline info, and enrichment data. Use for any company lookup. If no org matches, falls back to the users table — surfaces people who signed up with a matching email domain but have not yet created/joined an org (typical for inbound website signups). + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `create_contact` + +Create or find a contact record for a person by email. Writes to email_contacts (CRM) and person_relationships (engagement tracking). Returns whether the contact was new and whether they were auto-matched to an organization. + +*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_overview + +AdCP Academy catalog, module previews, learner progress, and earned credential checks + +### `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`* + +### `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`* + +### `check_credentials` + +Award any newly-eligible credentials and finalize any previously-deferred Certifier issuances for the current learner. Returns share links for newly-issued credentials, or a NAME_REQUIRED marker when the learner has no name on file. Use this after `set_my_name` to finalize a credential that was gated on the missing name. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `set_my_name` + +Set the current user's first name and (optionally) last name. Writes through to the local DB, organization memberships, and back to WorkOS. Use this when a credential check tells you the user has no name on file, or whenever the user explicitly asks to set or correct their name. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +## certification_learning + +AdCP Academy standard modules — start or continue teaching, checkpoint progress, run build exercises, and complete modules + +### `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`* + +### `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`* + +### `set_my_name` + +Set the current user's first name and (optionally) last name. Writes through to the local DB, organization memberships, and back to WorkOS. Use this when a credential check tells you the user has no name on file, or whenever the user explicitly asks to set or correct their name. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `check_credentials` + +Award any newly-eligible credentials and finalize any previously-deferred Certifier issuances for the current learner. Returns share links for newly-issued credentials, or a NAME_REQUIRED marker when the learner has no name on file. Use this after `set_my_name` to finalize a credential that was gated on the missing name. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `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`* + +### `call_adcp_task` + +Execute any registered AdCP protocol task against an agent. For uncommon tasks or when unsure about parameters, call ask_about_adcp_task first. +Does NOT include capability discovery — use the `get_adcp_capabilities` tool directly for that (it is not a task). + +Two rules a search round-trip cannot rescue you from after a mutating call: +• idempotency_key: REQUIRED on every mutating task (UUID). Same key on retry replays the same response. Generating a fresh UUID after a failed attempt is how you double-book. +• On adcp_error: read issues[].variants[] before retrying. It lists the exact valid shape — do not re-guess. + +Full buyer rules: ask_about_adcp_task with area="buyer". + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +### `call_adcp_get_products` + +Execute get_products with a machine-checkable request shape. +idempotency_key is required by this adapter and must remain identical when correcting or retrying the same logical request. +For buying_mode="brief", provide brief and omit refine. For "wholesale", omit both brief and refine. For "refine", provide refine and omit brief. + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +## certification_assessment + +AdCP Academy placement assessments and specialist capstones — test out modules, run exams, checkpoint, and complete credentials + +### `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 (any S-track module, 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 an available specialist deep dive module (S1: Media Buy, S2: Creative, S3: Signals, S4: Governance, S5: Sponsored Intelligence, S6: Security). The learner must hold the Practitioner credential. Returns the capstone format, lab exercises, and assessment criteria. You (Sage) will conduct the combined hands-on lab and adaptive exam — technically assess the learner against the spec. + +*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`* + +### `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`* + +### `set_my_name` + +Set the current user's first name and (optionally) last name. Writes through to the local DB, organization memberships, and back to WorkOS. Use this when a credential check tells you the user has no name on file, or whenever the user explicitly asks to set or correct their name. + +*Source: `server/src/addie/mcp/member-tools.ts`* + +### `check_credentials` + +Award any newly-eligible credentials and finalize any previously-deferred Certifier issuances for the current learner. Returns share links for newly-issued credentials, or a NAME_REQUIRED marker when the learner has no name on file. Use this after `set_my_name` to finalize a credential that was gated on the missing name. + +*Source: `server/src/addie/mcp/certification-tools.ts`* + +### `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`* + +### `call_adcp_task` + +Execute any registered AdCP protocol task against an agent. For uncommon tasks or when unsure about parameters, call ask_about_adcp_task first. +Does NOT include capability discovery — use the `get_adcp_capabilities` tool directly for that (it is not a task). + +Two rules a search round-trip cannot rescue you from after a mutating call: +• idempotency_key: REQUIRED on every mutating task (UUID). Same key on retry replays the same response. Generating a fresh UUID after a failed attempt is how you double-book. +• On adcp_error: read issues[].variants[] before retrying. It lists the exact valid shape — do not re-guess. + +Full buyer rules: ask_about_adcp_task with area="buyer". + +*Source: `server/src/addie/mcp/adcp-tools.ts`* + +### `call_adcp_get_products` + +Execute get_products with a machine-checkable request shape. +idempotency_key is required by this adapter and must remain identical when correcting or retrying the same logical request. +For buying_mode="brief", provide brief and omit refine. For "wholesale", omit both brief and refine. For "refine", provide refine and omit brief. + +*Source: `server/src/addie/mcp/adcp-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 +- A separate self-paid membership — send signed-in users to /onboarding?mode=personal; it bills their personal workspace, not the company +- 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`* + +## 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 (default: open), with total_count and next_offset. Use escalation_id for full details at any status. + +*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`* + +### `diagnose_signin_block` + +Use when a user reports they cannot sign in to AgenticAdvertising.org. Combines person, invite, and org-membership state into a single verdict — needs_signin (account exists, just sign in), needs_resend (invite expired or stale, send a fresh one), needs_invite (no invite on file, send one), or needs_human (state is unclear). Do NOT use for general account questions — use lookup_person or get_account for those. Returns the verdict plus a one-line reason and any pending invite token to act on. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `list_invites_for_org` + +Use when an admin asks "what invites do we have for X" or you need to reference an invite token before resending or revoking. Defaults to pending only; pass include_accepted or include_revoked to widen. Capped at 20, sorted by expiry (soonest first). Returns one invite per line with its token suffix so you can quote a token back into resend_invite or revoke_invite. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `resend_invite` + +Use to refresh a pending or expired membership invite — atomically revokes the original and emails a fresh one. Do NOT use to invite a brand-new contact (use send_payment_request for that). Do NOT use on an accepted invite. Returns the new expiry and whether the email was delivered. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `revoke_invite` + +Use to cancel a pending or expired invite (e.g. wrong email, person no longer joining). Does NOT send any notification to the recipient — their existing invite link will simply stop working. Cannot revoke an already-accepted or already-revoked invite. Returns confirmation including the previous status. + +*Source: `server/src/addie/mcp/admin-tools.ts`* + +### `add_member_to_org` + +Use when an admin or owner asks to add, invite, or promote a colleague on a paying org. Also the action to take when diagnose_signin_block returns needs_invite for an authorized requester. Handles four states automatically: invites new users (no WorkOS account yet), adds existing users to the org, updates roles, or no-ops if already correct. Do NOT use to remove members (no removal path), to change your own role, or for personal workspaces. Returns one of: invited, member_added, role_updated, no_change. + +*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_person_memory` + +Use when you need the full picture of what we know about a person — identity, membership, engagement, preferences, pending invites, recent threads — gathered into one view before reasoning. Prefer this over chaining lookup_person + get_account + list_invites_for_org. Do NOT use for org-level questions. Returns a structured summary with sources so you can cite where each fact came from. + +*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`* + +### `parse_brand_properties` + +Preview a smart-paste import for a brand the operator owns. Takes pasted text (a list of domains and app bundles) or a URL whose body should be fetched, and returns the structured property list that would be imported. The user MUST confirm before you call import_brand_properties — show them the parsed list first. Caller's organization must own the brand domain. + +*Source: `server/src/addie/mcp/brand-property-tools.ts`* + +### `import_brand_properties` + +Commit a previewed property import. Takes a property list (typically the output of parse_brand_properties, possibly trimmed by the user) and merges it into the brand manifest by identifier — existing entries are updated in place, new entries are added. ONLY call after the user has explicitly confirmed the list from parse_brand_properties. Caller's organization must own the brand domain. + +*Source: `server/src/addie/mcp/brand-property-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`* + +### `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`* + +### `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`* + +### `offer_portrait_generation` + +Explain the portrait generation feature to the member and provide a link to generate their portrait. Members upload a headshot and pick a vibe (at-my-desk, on-stage, in-a-studio, boardroom, casual). The result is an illustrated graphic novel portrait in amber/gold tones. + +*Source: `server/src/addie/mcp/portrait-tools.ts`* + diff --git a/dist/docs/3.3.0-beta.3/aao/connect-addie.mdx b/dist/docs/3.3.0-beta.3/aao/connect-addie.mdx new file mode 100644 index 00000000000..654824d60ef --- /dev/null +++ b/dist/docs/3.3.0-beta.3/aao/connect-addie.mdx @@ -0,0 +1,149 @@ +--- +title: Connect Addie to your AI client +description: Add the AgenticAdvertising.org 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 [AgenticAdvertising.org for members](/dist/docs/3.3.0-beta.3/aao/users) and the [Addie Tool Reference](/dist/docs/3.3.0-beta.3/aao/addie-tools). + +## What the MCP connection exposes + +Yes, Addie is available through MCP. The hosted server exposes her conversational interface as the `chat_with_addie` tool. Send a `message` and, for a multi-turn conversation, an optional `history` array: + +```json +{ + "message": "How does AdCP media buying work?", + "history": [ + { + "role": "user", + "content": "I'm implementing a buyer agent." + } + ] +} +``` + +`chat_with_addie` returns a `response` plus a `tools_used` list. It can search official documentation, public code repositories, and the public member, agent, and publisher directories while composing its answer. The MCP server also exposes specialized directory, validation, and agent-testing tools for clients that prefer structured calls. Tool discovery shows the server's complete catalog; individual tools can still require member or organization permissions. + +## Authentication at a glance + +The endpoint requires OAuth 2.1 user authentication on every request. Sign in with your AgenticAdvertising.org email; compatible clients handle the authorization flow and token refresh. Organization API keys authenticate the REST API, not MCP, and `/mcp` rejects them. + +For the underlying OAuth surface (authorization server metadata, dynamic client registration, scopes), see the [Reference URLs](#reference-urls) at the bottom of this page. + +## Validate uploaded JSON + +Upload your `adagents.json` and ask your client to validate the complete file. Clients that supply a file download reference can use `validate_json_file`: Addie downloads and validates the original JSON on the server, then returns its byte count, SHA-256 hash, and schema result. The download limit is 5 MiB. This checks the published schema; it does not check whether publisher or agent URLs inside the file respond. + +The tool declares ChatGPT's `openai/fileParams` metadata so compatible clients can pass the upload directly. Its `file` parameter accepts `download_url` and `file_id`, plus optional `file_name` and `mime_type`. The schema is selected from the file's `$schema`, an explicit `schema_path`, or the filename `adagents.json`. An optional `version` uses the same published-version selectors as `validate_json`. + +If your client cannot provide a download reference, use the [adagents.json builder](https://agenticadvertising.org/adagents/builder). A local filepath is not a download reference, and a shortened JSON copy only validates the content actually submitted. Ask the client which validation tool ran before relying on the result. + +## 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 AgenticAdvertising.org 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. + +## 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 that the bearer token came from the MCP OAuth flow. Organization API keys are REST-only credentials and will be rejected by `/mcp`. +- 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 /register` +- **Issue tracker:** [github.com/adcontextprotocol/adcp/issues](https://github.com/adcontextprotocol/adcp/issues) diff --git a/dist/docs/3.3.0-beta.3/aao/directory-api.mdx b/dist/docs/3.3.0-beta.3/aao/directory-api.mdx new file mode 100644 index 00000000000..b9081672b4c --- /dev/null +++ b/dist/docs/3.3.0-beta.3/aao/directory-api.mdx @@ -0,0 +1,267 @@ +--- +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 using [AdCP URL canonicalization](/dist/docs/3.3.0-beta.3/reference/url-canonicalization), the same convention the SDK applies in `verify_agent_authorization`: the scheme and host lowercase, a default port and a root trailing slash are dropped, dot segments resolve, and percent-encoded unreserved characters decode. Path case and a non-root trailing slash are significant — `/mcp` and `/mcp/` are different agents. + + +**Directory behavior change — 2026-10-18.** Through that date the AAO +directory at `agenticadvertising.org` folded the two spellings together and +answered a request for `https://a.example/mcp/` with +`https://a.example/mcp`'s publishers. That was an implementation bug — it +disagreed with this spec and with `verify_agent_authorization`, so the +directory could report an authorization the publisher's `adagents.json` does +not grant. Trailing-slash matching is now exact, as specified above. A caller +that asked with the wrong spelling and got rows will now get `404` (agent +never indexed) or `200` with an empty `publishers[]`; ask with the spelling +the publisher's `adagents.json` lists. The registry's own per-agent +*identity* surfaces (compliance, badges, monitoring) deliberately do fold the +two spellings — identity and authorization are separate questions. + + +A client that only holds an agent's registry identity URL cannot know which +spelling to ask for. The AAO registry listing answers that: each agent in +`GET /api/registry/agents` carries `declared_urls[]`, every spelling the +directory has indexed under that identity, with the listed (folded) URL +first. Query this endpoint once per spelling and merge the results — which +is what the public agent page does. + +### 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`, the agent's sell-side signatures about that publisher's inventory (such as webhooks) are accepted only with a key that is in both the pin and 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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/governance/property/adagents) — the trust root. +- [Managed Network Deployment](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/aao/org-admins.mdx b/dist/docs/3.3.0-beta.3/aao/org-admins.mdx new file mode 100644 index 00000000000..39ed368b836 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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.3.0-beta.3/aao/users). For Addie's full tool list, see the [Addie Tool Reference](/dist/docs/3.3.0-beta.3/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. +- **Check a sales agent's status.** *"What's the status of [agent URL]?"* runs `get_agent_status`, which returns the registry's cached health, declared capabilities, and the most recent comply storyboard verdict per track. For an on-demand live test, ask Addie to *"evaluate [agent URL]"* — `evaluate_agent_quality` runs the comply suite right now. +- **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.3.0-beta.3/aao/users.mdx b/dist/docs/3.3.0-beta.3/aao/users.mdx new file mode 100644 index 00000000000..ade7bb819d9 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/aao/users.mdx @@ -0,0 +1,74 @@ +--- +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.3.0-beta.3/aao/org-admins). For the full list of Addie's tools, see the [Addie Tool Reference](/dist/docs/3.3.0-beta.3/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. + +### Keep a personal membership alongside a company membership + +You can belong to a company organization and also maintain one separately billed personal workspace. Creating or subscribing to the personal workspace does not change the company's membership. + +1. Open [Create a personal workspace](https://agenticadvertising.org/onboarding?mode=personal) while signed in. +2. Choose Individual Professional (or another individual tier) and create the workspace. +3. The site selects the new personal workspace and opens its Membership page for checkout. + +If you already created a personal workspace, select it from the dashboard organization switcher before opening Membership. The organization shown in the switcher is the one Stripe will bill. + +## 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.3.0-beta.3/aao/addie-tools), she's probably wrong — push back and ask her to verify. diff --git a/dist/docs/3.3.0-beta.3/accounts/buyer-agent-self-onboarding.mdx b/dist/docs/3.3.0-beta.3/accounts/buyer-agent-self-onboarding.mdx new file mode 100644 index 00000000000..b4c98329983 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/buyer-agent-self-onboarding.mdx @@ -0,0 +1,213 @@ +--- +title: A buyer agent onboards itself +sidebarTitle: Buyer agent self-onboarding +description: "Follow a buyer agent from publishing its keys and trust record to a live account with a seller, with no seller-issued credential and no capabilities endpoint of its own." +"og:title": "AdCP — A buyer agent onboards itself" +--- + +This walkthrough follows one buyer agent from nothing to a working account with a seller it has never spoken to. The agent publishes its identity, reads the seller's onboarding posture, makes first contact with a signed request, and provisions an account. It never runs a capabilities endpoint of its own and never receives a seller-issued credential: the signature is the credential. + + +**Experimental, AdCP 3.3.** This flow uses several experimental surfaces: +- the Web Bot Auth signing profile ([Web Bot Auth identities](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities)); +- trust.json ([trust.json](/dist/docs/3.3.0-beta.3/reference/trust-json)); +- demand-side grants ([Grants in trust records](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental)); +- seller onboarding posture ([`account.agent_onboarding`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities#accountagent_onboarding)); +- relationship status on [`get_principal`](/dist/docs/3.3.0-beta.3/protocol/get_principal#relationship-status). + +They may change between 3.x releases with at least 6 weeks' notice. SDK support is in progress, so the examples show the wire, not SDK calls. Design and decisions: [#8118](https://github.com/adcontextprotocol/adcp/issues/8118), DR-0023, DR-0025. + + +## Scenario + +Sam runs buying at Pinnacle Agency. Pinnacle's buyer agent buys media for Nova Motors and other advertisers. Priya runs ad products at StreamHaus, a streaming publisher whose sales agent Pinnacle has never called. Sam wants the agent to open an operator-billed account with StreamHaus for Nova Motors, today, without a partnerships email thread. + +| | Pinnacle (buyer) | StreamHaus (seller) | +|---|---|---| +| Agent origin | `https://buyer.pinnacle-agency.example` | `https://ads.streamhaus.example` | +| Registrable domain | `pinnacle-agency.example` | `streamhaus.example` | +| Signing profile | `web-bot-auth` | verifies `web-bot-auth` and `adcp/request-signing/v1` | + +## What `open` onboarding delivers + +Self-onboarding automates becoming known. Under an `open` mode, a buyer agent gets a verified identity, sandbox access, and the ability to join a billing relationship that already exists, such as its operator's account with the seller. It does not let an unknown agent start spending: agent-billed accounts and new commercial relationships still go through the seller's application, with a human on each side. + +## 0. Publish your identity + +The agent's identity is its **origin**, one agent per origin. Three files make it verifiable. + +**The key directory**, at the agent's origin, holds its signing keys. It is a JWK Set; each `kid` is the key's RFC 7638 thumbprint, and keys carry no AdCP-specific members: + +```http +GET /.well-known/http-message-signatures-directory HTTP/1.1 +Host: buyer.pinnacle-agency.example + +HTTP/1.1 200 OK +Content-Type: application/http-message-signatures-directory+json +Content-Digest: sha-256=:...: +Signature-Input: key1=("@authority";req "content-digest");created=1791900000;expires=1791986400;keyid="NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";tag="http-message-signatures-directory" +Signature: key1=:...: + +{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "...", "kid": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" } ] } +``` + +The response is signed with each key and carries an `expires`, so publishing it is a scheduled job (regenerate it before it expires), not a one-time upload. + +**The trust record**, at the registrable domain, lists the origin and its role. It carries no keys: + +```json +{ + "agents": [ + { "url": "https://buyer.pinnacle-agency.example/mcp", "roles": ["adcp:buying"] } + ] +} +``` + +served at `https://pinnacle-agency.example/.well-known/trust.json`. Sellers derive this location from the agent's origin, so nothing points to it. + +**The revocation list**, at the agent's origin, is where the agent revokes a key. It is served at `/.well-known/governance-revocations.json` as a JWS of type `adcp-gov-revocation+jws`, signed with a key from the directory, even while it revokes nothing. Its payload: + +```json +{ + "version": 1, + "issuer": "https://buyer.pinnacle-agency.example", + "updated": "2026-10-13T14:55:00Z", + "next_update": "2026-10-13T15:10:00Z", + "revoked_kids": [] +} +``` + +The list is re-signed before its `next_update`, so publishing it is a scheduled job too. The format and polling rules are in [Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation). Without it, a seller can verify you but cannot bind you for spend. + +**Grants from the advertisers.** Nova Motors grants Pinnacle authority to buy for it, in Nova Motors' own trust.json: + +```json +{ + "grants": [ + { + "grantee": "pinnacle-agency.example", + "scopes": ["adcp:media_buying"], + "agents": [{ "url": "https://buyer.pinnacle-agency.example/mcp" }], + "profiles": { "adcp": { "brands": ["nova_motors"] } } + } + ] +} +``` + +A grant is published by the party making it, so the seller can verify it without trusting Pinnacle's word. + +## 1. Find sellers + +The agent finds StreamHaus through the AgenticAdvertising.org registry or the publisher's `adagents.json`, as today. + +## 2. Read the seller's onboarding posture + +An unsigned `get_adcp_capabilities` returns StreamHaus's posture. It is the same for every caller: + +```json +{ + "experimental_features": ["account.agent_onboarding"], + "account": { + "supported_billing": ["operator", "agent"], + "sandbox": true, + "agent_onboarding": { + "sandbox": { "mode": "open" }, + "billing": { + "operator": { "mode": "open", "requirements": [{ "kind": "grant" }] }, + "agent": { + "mode": "application", + "url": "https://streamhaus.example/partners/agent-billing", + "requirements": [{ "kind": "billing_entity" }, { "kind": "contract" }] + } + }, + "url": "https://streamhaus.example/partners/buyer-agents", + "message": "Sandbox is open to any verified buying agent. Operator-billed accounts are open when your operator already bills with us and the advertiser has granted your operator." + } + } +} +``` + +## 3. Check yourself against the requirements + +The agent compares each requirement with what it knows about itself: +- **Operator-billed (`open`, requires `grant`):** Nova Motors' trust.json grants Pinnacle `adcp:media_buying`, and Pinnacle already bills with StreamHaus. Usable now. +- **Agent-billed (`application`):** needs a billing entity and a contract. That goes to Sam, with the URL. +- **Unknown requirement kinds** would fall back to `message` and `url`. + +A seller marked `invite_only` would be dropped from the plan with the reason recorded. + +## 4. First contact + +The agent signs a `get_principal` request with the [Web Bot Auth profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile). The covered `Signature-Agent` member names its origin: + +```http +POST /mcp HTTP/1.1 +Host: ads.streamhaus.example +Content-Type: application/json +Content-Digest: sha-256=:...: +Signature-Agent: sig1="https://buyer.pinnacle-agency.example" +Signature-Input: sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest" "signature-agent";key="sig1");created=1791903600;expires=1791903900;nonce="feeG8NGBMs3K7BOSl6_Fw3kb1-bupF1WT-1iCRLNPaqfALoUmG8A0ZqhHWEck3K65l1JLYdv0BFklWhPKO0c-Q";keyid="NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";alg="ed25519";tag="web-bot-auth" +Signature: sig1=:...: +``` + +StreamHaus fetches the directory at the named origin, finds the key by thumbprint, verifies the signature, and checks the origin's revocation list. Pinnacle is now **identity-established**: StreamHaus knows exactly which origin is calling, but has not bound it to anything. `get_principal` returns: + +```json +{ "status": "completed", "result": { "kind": "unconfigured" } } +``` + +## 5. Configure the principal + +The agent calls `sync_principal` with its declarations and webhooks. Because StreamHaus's posture has an `open` mode, StreamHaus: +1. derives Pinnacle's trust record from the origin (`https://pinnacle-agency.example/.well-known/trust.json`); +2. confirms it lists `https://buyer.pinnacle-agency.example` with role `adcp:buying`; +3. creates a principal limited to the open modes. + +A seller without an `open` mode would hold this for its own approval. In that case `get_principal` shows `relationship.status: "pending_approval"`, with any applicant-specific `setup` step. + +## 6. Open the account + +The agent calls `sync_accounts` for Nova Motors, operator-billed: + +```json +{ + "accounts": [ + { + "brand": { "domain": "novamotors.example", "brand_id": "nova_motors" }, + "operator": "pinnacle-agency.example", + "billing": "operator" + } + ] +} +``` + +StreamHaus checks authority from Nova Motors' trust.json. The grant names Pinnacle, covers `adcp:media_buying`, and `account.operator` is Pinnacle, the signer's operator. Pinnacle already has an operator billing relationship with StreamHaus, so the account comes back `active`. Without the grant, the request would fail with `PERMISSION_DENIED` and `reason: "grant_absent"`. No account is created, and Sam knows to ask Nova Motors for the grant. + +## 7. Stay current + +The agent subscribes to `principal.changed`. When StreamHaus approves agent billing, suspends the relationship, or the relationship nears its `expires_at`, the webhook fires (`relationship_changed`, `relationship_expiring`), and the agent re-reads `get_principal`. A lapse arrives as a warning, not as `AGENT_SUSPENDED` in the middle of a campaign. + +## Failure branches + +| What happens | What the agent sees | What to do | +|---|---|---| +| The seller is `invite_only` for every mode | The posture | Drop the seller and record why. | +| The mode is `application` | The posture's `url` and requirements | Hand the URL and unmet list to a human. Watch `get_principal` for `relationship`. | +| The trust record doesn't list the origin | `PERMISSION_DENIED` with no `details` | Add the origin to `trust.json` with role `adcp:buying`. | +| No grant covers the brand | `PERMISSION_DENIED`, `reason: "grant_absent"` | Ask the advertiser, or the agency acting for it, to publish the grant. | +| The origin hosts two agents | `PERMISSION_DENIED` with no `details`, because the trust record fails the match | Give each agent its own subdomain. | +| Pinnacle revokes a key with `reason: keyCompromise` | Requests under that key get `AUTH_INVALID`; buys stand | Review the buys the seller shows you; pause or cancel the ones you didn't intend with `control_media_buy`. | + +## If you sign with the RFC 9421 profile today + +The RFC 9421 profile (`adcp/request-signing/v1`, keys through brand.json) is deprecated in 3.3 and works through 3.x. A buyer moving to Web Bot Auth publishes the directory, trust record, and revocation list above and keeps its brand.json keys until the sellers it uses have moved. A seller binds the new origin onto an existing principal only with explicit approval, so arrange that with each seller. See the [agent identity migration guide](/dist/docs/3.3.0-beta.3/reference/migration/agent-identity-3-3). + +## Related reference + +- [Web Bot Auth identities](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities) +- [First contact and binding](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#first-contact-and-binding) +- [Grants in trust records](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental) +- [`account.agent_onboarding`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities#accountagent_onboarding) +- [Relationship status](/dist/docs/3.3.0-beta.3/protocol/get_principal#relationship-status) +- [trust.json](/dist/docs/3.3.0-beta.3/reference/trust-json) +- [Set up your buyer agent with a seller](/dist/docs/3.3.0-beta.3/accounts/principal-registration-walkthrough): the principal layer in depth diff --git a/dist/docs/3.3.0-beta.3/accounts/overview.mdx b/dist/docs/3.3.0-beta.3/accounts/overview.mdx new file mode 100644 index 00000000000..fb08b5160c9 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/overview.mdx @@ -0,0 +1,455 @@ +--- +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 + +Seven questions underlie every AdCP transaction: + +| Question | Answered by | Mechanism | +| ---------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Who is the advertiser? | Brand registry | `brand.domain` resolves to `brand.json` | +| Who operates on the brand's behalf? | Brand registry | `authorized_operators` in `brand.json` declares who can buy on the brand's behalf | +| How does the operator authenticate? | Seller capabilities | `require_operator_auth` determines who must authenticate and which account reference shape is expected. | +| What am I allowed to do on this account? | Caller scope | The `authorization` object on each per-account entry in [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) responses describes `allowed_tasks`, `field_scopes`, `scope_name`, and `read_only` for the calling agent. See [Caller authorization](#caller-authorization) below. | +| Who gets billed? | Account terms | Buyer passes `billing` in `sync_accounts` — `operator`, `agent`, or `advertiser` — when a choice is needed. A lazy-provisioning seller uses an unambiguous capability or onboarding default. | +| Where is currency selected? | Seller capabilities | `supported_account_currency_modes` declares whether `currency` is fixed on the advertiser account, selected per media buy, or both. | +| Which calendar timezone applies? | Account capabilities | `account.timezone` declares seller-fixed versus account-fixed behavior. `Account.timezone` is the operational default; reporting, billing, or budget capabilities explicitly declare when they use another clock. | +| What was consumed? | Usage reporting | `report_usage` informs vendor agents how their services were used after delivery | + +Every account-scoped task names an account. The seller's [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) response tells the buyer how to get one, and `require_operator_auth` is the field that decides it. Read it as **who holds the credential the seller authorizes**: + +| `require_operator_auth` | Who authenticates | Where accounts come from | Reference on later calls | +| --- | --- | --- | --- | +| `false` (default) | The buyer agent, once | The buyer opens them with `sync_accounts`, or a lazy-provisioning seller creates them on first use | The `account_id` the seller returns | +| `true` | Each operator, separately | They already exist on the seller; the buyer finds them with `list_accounts` or receives them out-of-band | `account_id` | + +The operator in a natural key is a property of the account: the agency or brand the account is for, and whom the seller checks against `brand.json` and may invoice. It is not an authenticated party. With `require_operator_auth: false`, no operator authenticates; the agent does, and names the operator in the payload. `require_operator_auth` does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. + +When `require_operator_auth` is `true` (**account-id namespaces**), operators authenticate independently and buyers pass seller-assigned `account_id` values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) and buyers MUST resolve an explicit `account_id` before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose `list_accounts` returning that singleton, and MAY omit it only when the same explicit `account_id` is supplied through another declared path or out-of-band onboarding. + +When `require_operator_auth` is `false` (**buyer-declared accounts**), the agent is trusted. The buyer opens each account with `brand` + `operator` and sends the returned `account_id` on later calls. Most sellers provision these relationships through [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). A seller MAY instead lazily provision on the first provisioning task, such as `create_media_buy`, when billing and every other required setting are unambiguous from capabilities or the authenticated agent's onboarding defaults. Discovery tasks never provision; see [Account references before provisioning](#account-references-before-provisioning). A lazy-provisioning seller MUST keep accepting the natural key and MUST expose [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) for recovery. If buyer input is required before use, the seller MUST expose `sync_accounts`. + +Follow [Provision a seller-mediated account](/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough) for a complete buyer-declared workflow from discovery through human setup, activation, and first spend. Follow [Set up your buyer agent with a seller](/dist/docs/3.3.0-beta.3/accounts/principal-registration-walkthrough) for the standing, caller-scoped setup that comes before accounts: webhooks, reusable reporting destinations, and declarations. + +An **ad network** may use both models simultaneously — buyer-declared accounts on the buyer-facing side (the network is agent-trusted) and an account-id namespace with each underlying platform (the network authenticates as an operator). See the [Sponsored Intelligence guide — account model for networks](/dist/docs/3.3.0-beta.3/sponsored-intelligence/networks#account-model-for-networks) for the full account chain: `buyer agent → network (buyer-declared) → AI platform (account-id namespace)`. + +After delivery, the orchestrator calls [`report_usage`](/dist/docs/3.3.0-beta.3/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 is the seller-assigned `account_id` in both models. In this vocabulary, **brand identity** is the brand's `domain`, plus `brand_id` where its `brand.json` lists several brands. **Operator identity** is the operator's domain, and **account identity** is the `account_id`. `brand.countries`, `operator_unit`, `currency`, `timezone`, and `sandbox` are account attributes, not identity. `operator_unit.id` is deliberately separate from the seller's `account_id`. During 3.x, a buyer may still send the natural key for a buyer-declared account: `brand` + `operator`, with attributes as filters. It resolves only when exactly one account matches. See [Account identity](#account-identity) and [Account references](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references) for details. + +`billing` names the party the seller invoices for this account relationship. It is not a payment rail or settlement selector. AdCP does not currently standardize per-media-buy choice between an intermediary clearing flow and direct settlement; implementations must not encode that distinction by silently changing the meaning of `billing`. + +## Account references before provisioning + +A buyer-declared natural key finds an advertiser account. It does not create one. An account is **provisioned** once `sync_accounts` has returned it without `action: "failed"`, or once a lazy-provisioning seller has created it on a provisioning task (below). Before that, the key does not resolve. After that, [account status](#operations-by-account-status) decides which tasks the account can use. + +**Only provisioning tasks provision.** A lazy-provisioning seller provisions only on a task that commits spend or creates account-owned resources, such as [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy), [`buy_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/buy_products), [`accept_proposal`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/accept_proposal), [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives), [`sync_catalogs`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_catalogs), [`sync_event_sources`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_event_sources), or [`activate_signal`](/dist/docs/3.3.0-beta.3/signals/tasks/activate_signal). Discovery and negotiation tasks, including [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products), [`list_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/list_products), [`get_signals`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals), [`request_proposals`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/request_proposals), [`refine_proposals`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/refine_proposals), and [`decline_proposals`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/decline_proposals), MUST NOT create or activate an account or accept the seller's default terms on its behalf. + +**An unresolved account is an error, not a fallback.** When a request carries an `account` that does not resolve, the seller MUST return `ACCOUNT_NOT_FOUND`, including on tasks where `account` is optional. It MUST NOT drop the reference and answer with public results: `cache_scope: "public"` on an account request says that this account prices off the rate card, which is a claim about an account that does not exist. One exception: a seller that lazily provisions instead of exposing `sync_accounts` MAY answer a discovery task for a natural key it would provision on the next provisioning task. It answers as if that account existed with the settings it would get, `cache_scope` follows the usual rule for that account, and the account is not created. `list_accounts` is different: its `account` field is a filter, so a key that matches nothing returns an empty list. + +**Buyers omit unprovisioned keys.** Until an account is provisioned and its status allows the task, buyers SHOULD omit `account` on discovery tasks and send top-level `brand` where the task accepts it. The seller answers with its public view (`cache_scope: "public"`). When the seller declares `account.required_for_products: true`, the buyer provisions the account before calling `get_products`. A buyer that intends to accept a proposal provisions the account before `request_proposals` and sends it there, so the proposal is bound to that account. Buyer SDKs SHOULD store the `account_id` of each account the buyer opens and apply these rules by default, so each implementer doesn't have to. + +**Account errors describe setup, not seller health.** `ACCOUNT_NOT_FOUND`, `ACCOUNT_SETUP_REQUIRED`, and `ACCOUNT_PAYMENT_REQUIRED` describe the relationship between one buyer and one seller. Buyers SHOULD NOT count them toward a seller's availability or health. To recover from `ACCOUNT_NOT_FOUND` on a natural key, provision through `sync_accounts` (or omit `account` for public discovery). For an `account_id`, verify it through `list_accounts`. + +**After opening, references use the account ID.** Sellers MUST return `account_id` from `sync_accounts` once the account is resolved, accept it on every account-scoped task, and never change it. Buyers store it and send `{ "account_id": "..." }`. The seller keeps accepting a natural-key reference throughout 3.x. See [Account identity](#account-identity). + +## Account identity + +Every party to an account is identified by a domain, verified by what that domain publishes: + +| Party | Identity | Verified through | +| --- | --- | --- | +| Agent | Its origin | Its [Web Bot Auth](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities) key directory | +| Operator | Its registrable domain | Its [`trust.json`](/dist/docs/3.3.0-beta.3/reference/trust-json) and the brand's `authorized_operators` | +| Brand | Its domain, plus `brand_id` where that domain's `brand.json` lists several brands | Its [`brand.json`](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json) | + +An account is identified by the `account_id` the seller issues when the account opens. Nothing else identifies it: + +- **Open.** A [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) entry without `account` opens an account for a brand and an operator, and the seller returns its `account_id` once the account is resolved. A seller never issues an `account_id` it might later change, so a `pending_approval` row that may still resolve to a different account carries an experimental `request_id` instead. A retry with the same `idempotency_key` returns the original result. When an entry matches exactly one account in the seller's [account scope](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-scope), the seller SHOULD return that `account_id` instead of opening another. The match follows the [`request_account` deduplication guidance](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account#deduplication): `brand` and `operator`, with the attributes as filters and billing terms never filtering. The exact-or-reject rules for `billing` and `payment_terms` still apply. An entry whose attributes differ, such as its `countries`, opens a second account with its own `account_id`. A provisioning entry never changes an existing account. A lazy-provisioning seller opens the account on the first provisioning task instead. +- **Reference.** After the account opens, buyers send `{ "account_id": "..." }`. A natural-key reference (`brand` + `operator`) resolves only when exactly one of the caller's accounts matches it. Each supplied attribute must equal the account's value: `countries` compares as a set, and `operator_unit` compares by `id`. An omitted attribute does not filter, except `sandbox`, which defaults to `false`. When several accounts match, the seller returns [`ACCOUNT_AMBIGUOUS`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-ambiguous) and MAY list them in `error.details.candidates`. +- **Update.** `brand.countries`, `operator_unit`, `currency`, `timezone`, and `sandbox` are account attributes. The buyer sets them when the account opens, and a `sync_accounts` entry with `account` updates them, as it updates `billing`. The seller validates a changed attribute as it does when the account opens. The `account_id` stays the same. A seller that does not support changing an attribute rejects the entry with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature). `currency`, `timezone`, and `sandbox` are set at opening: a seller MAY refuse a `currency` or `timezone` change, changing one normally means a new account, and `sandbox` never changes. A seller can review a `countries` change first, and the entry's result then reports `pending_approval` while the account keeps its current countries; see [Updating account attributes](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts#updating-account-attributes). +- **Countries.** `brand.countries` declares the markets the account serves. It is not identity and does not target delivery. A seller MAY use it to decide which products, prices, terms, and reviews apply to the account, and MAY refuse a new media buy or package whose targeting reaches a market outside it. A change applies to later requests only. Narrowing `countries` never changes, pauses, or cancels a media buy the seller has already accepted, and never changes its targeting, pricing, or terms. Widening it never extends an accepted buy into the new markets. +- **Accounts that end up alike.** A change can leave an account with the same brand, operator, and attributes as another account: for example, a Nova Athletics account serving every European market narrows to NL while a separate Nova Athletics NL account already exists. That is not a conflict. Identity is the `account_id`, so both accounts keep their IDs, media buys, and history, and the seller MUST NOT merge them. The seller MAY apply the change or refuse it. Where the seller supports `account.requests`, it MAY instead resolve the change request to the account the caller already holds (`resolution: matched`), or to a separate new account (`resolution: created`) when the request allowed one with `allow_separate_account`, leaving the named account unchanged; see [Changing an account](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account#changing-an-account). Once two of a caller's accounts are alike, a natural-key reference that matches both returns [`ACCOUNT_AMBIGUOUS`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-ambiguous); use the `account_id`. The deprecated `ACCOUNT_IDENTITY_CONFLICT` does not apply. + +Because an account's identity never changes, its `account_id` keeps naming it. No former key exists to tombstone or redirect, and no update can collide with another account's identity. + +Sellers that review requests by hand, or may link a request to an existing account, can offer [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) (experimental in 3.3). A request is its own object, so no `account_id` exists until it resolves, a match never changes an ID, and `pending_action.waiting_on` says whose turn it is. + + + **Deprecated in 3.3: the natural key as the account reference after opening.** Sending the natural-key `AccountRef` on account-scoped tasks once an account exists is deprecated and is planned for removal in AdCP 4.0 ([#7850](https://github.com/adcontextprotocol/adcp/issues/7850)), under the standard [deprecation policy](/dist/docs/3.3.0-beta.3/reference/versioning#deprecation-policy). It keeps working throughout 3.x, and the natural key stays valid as a [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) filter. + + **Deprecated in 3.3, planned for removal in 4.0: the identity-rekey machinery.** The `account.identity_updates` capability, `operator_identity` reconciliation through `sync_accounts`, and the `account.stable_account_id` capability are deprecated. So are the `ACCOUNT_MOVED` and `ACCOUNT_IDENTITY_CONFLICT` error codes. Sellers that implement `operator_identity` keep it working through 3.x. `stable_account_id` always holds, so buyers send `account_id` without checking it. + + +## 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, as shown below. Mutation operations are the most restricted. + +| Task | `active` | `pending_approval` | `payment_required` | `suspended` | `rejected` / `closed` | +| ------------------------ | -------- | ------------------ | ------------------ | ----------- | --------------------- | +| `list_accounts` | Yes | Yes | Yes | Yes | Yes | +| `get_account_financials` | Yes | Yes | Yes | Yes | No | +| `get_products` | Yes | No | Yes | No | No | +| `create_media_buy` | Yes | No | No | No | No | +| `update_media_buy` | Yes | No | Yes | No | No | +| `get_media_buys` | Yes | No | Yes | Yes | No | +| `sync_creatives` | Yes | No | Yes | No | No | +| `sync_catalogs` | Yes | No | Yes | No | No | +| `sync_event_sources` | Yes | No | Yes | No | No | +| `report_usage` | Yes | No | Yes | Yes | No | + +- `payment_required` blocks new spend (`create_media_buy`) but allows managing existing buys and resolving setup. Sellers SHOULD also reject `new_packages` within `update_media_buy` when the account is in `payment_required`, since adding packages is functionally equivalent to new spend. +- `suspended` allows read-only access to existing data but blocks all mutations +- Sellers MUST return `ACCOUNT_SUSPENDED` for blocked operations on suspended accounts and `ACCOUNT_PAYMENT_REQUIRED` for blocked operations on payment-required accounts + +## Caller authorization + +Not every caller with access to an account has the same grant. A vendor agent may issue one calling agent a full scope and another agent a narrow read-plus-update scope. Authentication confirms the caller is who they claim to be; authorization answers "what is this caller allowed to do on this account?" + + + **Applies to every vendor protocol.** The authorization mechanism described + here is part of the shared Accounts Protocol — it applies to every agent that + implements `sync_accounts` / `list_accounts`: media-buy sales agents, signals + agents, governance agents, creative agents, brand agents. Signals agents scope + activation vs. catalog access; governance agents scope audit read vs. plan + management; creative agents scope library read vs. upload. Only the standard + named scope `attestation_verifier` is Media Buy Protocol-specific (it binds to + the **AAO Verified (Live)** qualifier); the rest of the machinery — + `allowed_tasks`, `field_scopes`, `read_only`, `custom:`-prefixed scopes — is + protocol-neutral. + + +**Schema**: [`/schemas/3.3.0-beta.3/core/account-authorization.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/account-authorization.json) + +Vendor agents that support scope introspection attach an `authorization` object to each per-account entry in [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) responses: + +```json +{ + "account_id": "acc_acme_compliance", + "name": "Acme c/o AAO Compliance", + "status": "active", + "billing": "operator", + "authorization": { + "allowed_tasks": [ + "get_adcp_capabilities", + "get_products", + "get_media_buys", + "get_media_buy_delivery", + "list_creatives", + "update_media_buy" + ], + "field_scopes": { + "update_media_buy": ["reporting_webhook"] + }, + "scope_name": "attestation_verifier", + "read_only": false + } +} +``` + +### Fields + +| Field | Description | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `allowed_tasks` | Canonical snake_case task names the caller may invoke against this account. Absence of a task MUST be read as "not permitted" — invoking an absent task returns `SCOPE_INSUFFICIENT`. | +| `field_scopes` | Optional per-task allowlist of request fields the caller may set. Keys are task names; values are field paths. When a task appears here, any field outside the allowlist returns `FIELD_NOT_PERMITTED`. Implicit framing fields — typed entity references (`account`, `media_buy_id`, `package_id`, `creative_id`, `signal_id`, `capability_id`, `format_option_ref`, deprecated `format_id`, `proposal_id`, `plan_id`, `session_id`), concurrency/idempotency (`revision`, `idempotency_key`), buyer-side correlation (`buyer_ref`, `po_number`), mode flags (`dry_run`), pagination (`pagination`, `cursor`, `max_results`), and envelope fields (`context`, `ext`, `adcp_major_version`, `push_notification_config`) — are always permitted and do not need to appear in the allowlist. The list is non-exhaustive: any other typed entity-id parameter or query-shaping field on a read task SHOULD be treated as framing. | +| `scope_name` | Optional named scope identifier. Only `attestation_verifier` is standardized (media-buy-specific, binds to the **AAO Verified (Live)** qualifier); agent-defined names MUST use the `custom:` prefix so typos of the standard value fail schema validation rather than pass through. | +| `read_only` | Convenience flag. When true, mutations return `READ_ONLY_SCOPE` regardless of whether the task is in `allowed_tasks`. Omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone. | + +**AdCP 3.2 compact product tools.** Authorization names the actual task. A `get_products` grant does not silently authorize `request_proposals`, `refine_proposals`, or `decline_proposals`; sellers grant the proposal lifecycle explicitly and may scope its compact top-level fields independently. A read-only grant may permit `list_products` but rejects the three mutation-capable proposal tools. + +### Semantics of presence and absence + +- **Present**: the vendor agent asserts the shape reflects its enforcement for this caller on this account at this moment. Stale-by-seconds is fine; systematically-divergent is non-conformant. +- **Absent on a single account**: the vendor agent is not telling the caller their scope for that account. Caller falls back to error-driven discovery (try the task, handle the RBAC error codes). +- **Absent on all accounts**: the vendor agent does not implement scope introspection. Callers MUST NOT infer access from absence — the vendor agent may still enforce scope locally and return `SCOPE_INSUFFICIENT` / `READ_ONLY_SCOPE` / `FIELD_NOT_PERMITTED` on any task call. +- **`authorization` is optional for 3.x**. Vendor agents that want to avoid a breaking change can defer populating it. Populating is strictly additive — it lets callers preempt errors they would otherwise discover by trying. + +Vendor agents claiming the `attestation_verifier` standard scope (Media Buy Protocol-specific) **MUST** populate `authorization` — the **AAO Verified (Live)** attestation flow depends on verifying the advertised scope matches enforcement. + +### Identity binding, refresh cadence, and consistency + +The `authorization` object is **implicitly scoped to the `(caller identity, account_id)` tuple** at read time. The same account returned to a different authenticated caller MAY return a different `authorization` object — that's the point of the RBAC model. Vendor agents MUST resolve caller identity from the authenticated request (not from any client-supplied field) and MUST bind the returned `authorization` to that resolved identity. + +**Refresh cadence.** + +- Callers SHOULD re-read `authorization` at least every **300 seconds** of active use against an account, via `sync_accounts` or `list_accounts` (filtered by the held `account`). +- Vendor agents MUST reflect operator-initiated scope changes in `sync_accounts` / `list_accounts` responses **within 300 seconds** of the change being made. A compliant caller polling every 300s sees operator changes within at most one refresh cycle. +- Vendor agents MAY cache the `authorization` object briefly but MUST NOT cache past 300 seconds without revalidation. +- The 300s figure is a floor, not a target — vendor agents whose scope changes more frequently SHOULD surface them faster, and callers running **AAO Verified (Live)** attestation SHOULD probe at the check-7 cadence (at least once per rolling window, plus on every observed scope change). + +**Consistency.** + +For a given `(caller identity, account_id)` tuple, sequential reads within the refresh window MUST return identical `authorization` objects, modulo operator-initiated scope changes. Flicker from load-balanced or eventually-consistent backends — two reads 10 seconds apart returning different `allowed_tasks` because they hit different replicas — is non-conformant. Compliance engines and coding agents rely on scope stability for state tracking; a vendor agent that cannot guarantee it MUST omit `authorization` rather than populate it inconsistently. + +This is the concrete form of the "systematically-divergent is non-conformant" guarantee from the presence semantics above — it's verifiable: a conformance check can read the same account twice from the same identity inside the refresh window and diff the results. + +### Buyer response to SCOPE_INSUFFICIENT within the refresh window + +A single `SCOPE_INSUFFICIENT` response is observationally indistinguishable between two causes: a seller replica that has not yet propagated a valid grant (transient infrastructure artifact — will resolve), and a legitimate scope reduction by the operator (persistent — must surface). Before classifying the error as a definitive `correctable` signal requiring operator intervention, buyers MAY exhaust a small bounded retry budget to disambiguate the two: + +- **Retry budget:** no more than 3 attempts, each separated by 1–5 seconds of jittered backoff. This is disambiguation logic — establishing whether the scope is genuinely insufficient before trusting the `correctable` classification — not a recovery action for a correctable error. +- **Not the 300s window:** the retry budget is not the seller's propagation SLA. Buyers SHOULD NOT wait out the full 300-second window on every false negative; up to 15 seconds of accumulated backoff delay is sufficient to absorb typical replica lag. +- **After retries exhaust:** buyers MUST surface the error. "Surface" means: return a structured error to the calling layer (including account ID, the failed task, and `error.details.retry_count` with the number of attempts made) AND ensure the failure is visible in an operator-accessible channel — a structured log, dashboard alert, or notification. The escalation mechanism is implementation-defined; the requirement is that the error is not silently swallowed. + +`READ_ONLY_SCOPE` follows the same bounded-retry logic when the buyer suspects grant-propagation lag (a replica lagging on a write-grant update). Caution: if write access was recently _revoked_, a stale replica may accept a mutation that the revoked scope should have rejected. If bounded retries succeed, buyers MUST re-read `authorization` before treating the result as reliable — and if re-reading confirms that write access has been revoked, buyers MUST surface an alert to the operator flagging the mutation as potentially unauthorized; they MUST NOT silently accept it as correct. + +`FIELD_NOT_PERMITTED` does not follow this pattern. The agent-autonomous recovery path — strip the disallowed field and resubmit — supersedes the retry consideration. Buyers MUST NOT retry the identical failing request for `FIELD_NOT_PERMITTED`; they SHOULD correct and resubmit immediately. + +See [Authorization (RBAC)](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling#authorization-rbac) for the normative retry exception clause on these codes. + +### Standard named scope: `attestation_verifier` + +Media-buy sales agents advertising **AAO Verified (Live)** readiness (tracked in [#2965](https://github.com/adcontextprotocol/adcp/issues/2965)) MUST support a named scope identified by `scope_name: "attestation_verifier"` with the following minimum shape: + +- `allowed_tasks` (at minimum — vendor agents MAY include additional read-only tasks): + - `get_adcp_capabilities` + - `get_products` + - `get_media_buys` + - `get_media_buy_delivery` + - `list_creatives` + - `update_media_buy` +- `field_scopes.update_media_buy`: `["reporting_webhook"]` +- `read_only`: `false` + +The scope deliberately omits `create_media_buy`, `sync_creatives`, and all spend-committing or targeting-modifying fields on `update_media_buy`. It is narrowly designed for continuous-observability verification — the compliance engine can discover live campaigns, read inventory and creative state, attach a verification reporting webhook, and read delivery, but cannot book inventory, modify budgets, change flight dates, upload creatives, or cancel anything. + +`get_products` and `list_creatives` are included because **AAO Verified (Live)** observability requires sanity-reading the seller's declared inventory and the creative pipeline state on active buys. + +`attestation_verifier` is Media Buy Protocol-specific — it binds to the **AAO Verified (Live)** qualifier, which is a media-buy flow (live observation requires real ad delivery). Equivalent observability scopes for signals, governance, creative, or brand agents are not yet standardized; until they are, those agents use `custom:` scopes. + + + **Reporting only — lifecycle-running is a future scope.** + `attestation_verifier` is the *reporting* half of (Live): the engine observes + campaigns the seller has trafficked, reads delivery, and attaches a + verification webhook. It deliberately cannot create campaigns, attach + creatives, or modify budgets. The complementary *lifecycle-running* role — for + the AAO-operated canonical-campaign runner contemplated in + [#3046](https://github.com/adcontextprotocol/adcp/issues/3046), where the + engine traffics canonical PSAs end-to-end through a seller's live agent — + needs a broader write scope (`attestation_runner`, tracked in + [#3561](https://github.com/adcontextprotocol/adcp/issues/3561)). Today's + brownfield enrollment (Path B) requires only `attestation_verifier`; the + runner-side scope lights up alongside the canonical-campaign runner itself. + + +### Custom scopes for other vendor protocols + +Any vendor agent MAY define custom scopes using the `custom:` prefix. Buyers MUST NOT assume any semantics from a custom-prefixed scope name — the names are agent-defined and learned out-of-band (docs, onboarding). + +Illustrative examples (not standardized — each agent names its own): + +- **Signals agent**: `custom:activation_only` — `allowed_tasks: [get_signals, activate_signal, get_adcp_capabilities]`, no catalog management, no cross-account metadata. +- **Governance agent**: `custom:audit_viewer` — `allowed_tasks: [get_plan_audit_logs, get_adcp_capabilities]`, `read_only: true`. Useful for regulators or external auditors granted read access to governance trails. +- **Creative agent**: `custom:library_reader` — `allowed_tasks: [get_adcp_capabilities, list_creatives]`, `read_only: true`. Lets a non-uploading buyer (e.g., a measurement partner) discover capabilities and library contents without mutating them. +- **Measurement agent on a buyer-orchestrator gateway account**: `custom:measurement_feedback` — `allowed_tasks: [get_media_buy_delivery, provide_performance_feedback]`, `read_only: false`. These are the fixed tasks in the first experimental gateway tier. Measurement providers do not receive seller-account grants. The exact task list is normative, so no standard named scope is needed. +- **Brand agent (rights)**: `custom:rights_viewer` — `allowed_tasks: [get_rights, get_brand_identity]`, `read_only: true`. Discovery without clearance privilege. + +The presence/absence semantics, identity binding, refresh cadence, and consistency requirements above apply uniformly across all vendor protocols — a signals agent populating `custom:activation_only` takes on the same 300s refresh obligation a media-buy seller does populating `attestation_verifier`. + +### Prior art + +The introspection model — "the caller asks the authorization-enforcing party what the grant is" — is structurally analogous to [RFC 7662 OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662), specialized for AdCP's task-and-field authorization model. Embedding the response in sync/list rather than splitting it into a separate task reflects that account discovery and scope introspection are the same natural question ("what are my accounts, and what can I do with them?") — the two are returned together. + +## Transaction lifecycle + +``` +1. Discover seller capabilities + get_adcp_capabilities → require_operator_auth, supported_billing + +2. Resolve brand identity + Fetch brand.domain/.well-known/brand.json → canonical brand (domain, brand_id) + +3. Verify operator identity + Check authorized_operators in brand.json → confirm operator is permitted to buy for this brand + +4. Authenticate (if required) + When require_operator_auth is true → obtain operator credential via authorization_endpoint or out-of-band + +5. Establish account reference + Account-id namespace (require_operator_auth: true): + list_accounts() → find existing account_id for this brand/operator (upstream-managed) + or receive account_id out-of-band (seller-defined) + Buyer-declared account (require_operator_auth: false): + sync_accounts({ accounts: [{ brand, operator, billing, billing_entity? }] }) → account_id, status, billing terms + or, when all required settings are unambiguous, send the natural key on the first account-scoped task + list_accounts() → recover account IDs after a cold start (required for lazy provisioning; otherwise SHOULD be exposed) + +6. Execute + Protocol tasks use the account reference to apply correct rates and terms + Send { account_id } + 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.3.0-beta.3/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` or `sync_accounts`. A seller that supports the experimental `account.requests` surface also exposes `request_account` (open or change an account) and `list_accounts` (read accounts and requests); buyers SHOULD use those with it, and `sync_accounts` remains available for buyers and sellers that don't. A seller-defined account-id namespace MAY omit both only when account IDs are supplied out-of-band and no account settings are managed through AdCP. Buyer-declared-account sellers normally expose `sync_accounts` to establish the relationship and SHOULD also expose `list_accounts` for cold-start recovery. A stateless buyer that lost its account IDs cannot recover them by replaying `sync_accounts`, which opens accounts. `list_accounts` therefore returns each account's `account_id` with its brand, operator, and attributes. A buyer-declared seller MAY omit `sync_accounts` only when it lazily provisions from a natural key, can resolve every required setting without buyer input, and exposes `list_accounts`. For account-id namespaces, `sync_accounts` is settings-update only in 3.0.x unless a future explicit capability declares account-id provisioning. See [Required tasks by protocol](/dist/docs/3.3.0-beta.3/protocol/required-tasks#any-agent-accepting-accounts). + +| Task | Purpose | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) | Declare brand/operator pairs and billing; provision accounts (buyer-declared accounts, `require_operator_auth: false`); update settings and attributes of existing accounts (optional for account-id namespaces) | +| [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) | Discover existing accounts in upstream-managed namespaces; recover acknowledged buyer-declared accounts after a cold start; poll status on pending accounts | +| [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) | Experimental (3.3). The single account write: open an account (the seller matches or creates it) or change one by `account_id`, possibly after human review on either side. Read requests back with `list_accounts` (`include_requests`) | +| [`get_account_financials`](/dist/docs/3.3.0-beta.3/accounts/tasks/get_account_financials) | Query spend, credit, and invoice status for operator-billed accounts | +| [`sync_governance`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) | Sync governance agent endpoints to accounts for seller-side validation | +| [`report_usage`](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/accounts/principal-registration-walkthrough.mdx b/dist/docs/3.3.0-beta.3/accounts/principal-registration-walkthrough.mdx new file mode 100644 index 00000000000..1607ad65244 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/principal-registration-walkthrough.mdx @@ -0,0 +1,437 @@ +--- +title: Set up your buyer agent with a seller +sidebarTitle: Buyer agent setup +description: "Walk a buyer agent from seller-issued credentials to registered webhooks, a proven reporting destination, account feed binding, and the kill switch." +"og:title": "AdCP — Set up your buyer agent with a seller" +--- + +This walkthrough follows one buyer agent through its first standing relationship with a seller: who it authenticates as, what it declares, where reports and webhooks go, and how that delivery is switched off. It is the caller-scoped setup that comes before account provisioning. + + +**Experimental.** The principal layer ([`sync_principal`](/dist/docs/3.3.0-beta.3/protocol/sync_principal), [`get_principal`](/dist/docs/3.3.0-beta.3/protocol/get_principal), the `adcp.principal` capability block, and the `principal.changed` webhook) is an experimental AdCP 3.2 surface. It may change between 3.x releases with at least 6 weeks' notice. Sellers implementing any part of it MUST declare `protocol.principal` in `experimental_features`. See [experimental status](/dist/docs/3.3.0-beta.3/reference/experimental-status) for the full contract. + + +## Scenario + +Pinnacle Agency's buyer agent will buy from Priya's StreamHaus sales agent. Sam, who runs Pinnacle's buying, wants the relationship set up before any media buy: webhooks registered once, a reusable reporting bucket proven, and a way to stop delivery if a bucket is ever compromised. In the steps below, "the agent" is Pinnacle's buyer agent acting under its own credential, and Sam is the person who reviews and operates it. + +Two things are deliberately **not** part of this flow: + +- **There is no `register_buyer_agent` task.** AdCP begins after authentication. StreamHaus's authorization system decides who its callers are. +- **Nothing here grants access to an advertiser account.** The principal layer says where and how the principal receives data. Which advertisers it may act for is established separately through [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). + +## 1. Get credentials from the seller + +Credentials come from StreamHaus, out of band from AdCP: an OAuth client for the agent's workload identity, an API key issued to the agent, or a verified signed-request identity published in Pinnacle's `brand.json`. The agent then authenticates as itself on every call, and StreamHaus resolves that credential to one durable principal record with `principal_kind: buyer_agent`. + +Which credential shape StreamHaus offers is its authorization design. The protocol requires only that the credential map to a *stable* principal: rotating a key or renewing a token preserves the configuration only when StreamHaus maps the new credential to the same principal, and a raw key ID, token, or session is never the owner. For a signed caller, the stable subject is the canonical `brand.json` `agents[].url`, so two agent URLs under Pinnacle are two principals. + +Sam reaching StreamHaus through Claude or ChatGPT does so through Pinnacle's buyer agent, so the agent is still the principal. A principal is always a bound buyer agent; the operators it acts for are facts on its accounts. If a client can present only one shared service credential for all its users, StreamHaus must refuse to persist configuration under it. + +**Principals do not merge.** Each agent origin is its own principal, with its own destinations and suspension controls. Linking two principals needs an explicit seller-side administrative operation. + +Never send identity in a request body. `sync_principal` and `get_principal` reject `buyer_agent_url`, `agent_url`, `principal_id`, and `connection_id`, and a seller never derives identity from them. + +## 2. Discover what the seller supports + +The agent calls [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) and reads the `adcp.principal` block together with the seller's reporting offerings: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/get-adcp-capabilities-response.json", + "status": "completed", + "adcp": { + "major_versions": [3], + "supported_versions": ["3.2"], + "idempotency": { + "supported": true, + "replay_ttl_seconds": 86400 + }, + "capability_changes": { + "capabilities_version": "streamhaus-2026-09-01", + "cache_ttl_seconds": 300, + "notifications": { + "supported": true, + "registration_task": "sync_principal", + "event_types": ["capabilities.changed"] + } + }, + "principal": { + "supported": true, + "sync_task": "sync_principal", + "read_task": "get_principal", + "supported_sections": [ + "notification_configs", + "reporting_destinations", + "declarations" + ], + "caller_event_types": ["capabilities.changed", "principal.changed"], + "max_reporting_destinations": 16, + "reporting_destination_offerings": [ + { + "pattern": "file_transfer", + "transports": ["s3", "gcs"], + "formats": ["parquet", "csv"], + "verification_profiles": ["manifest_checksums", "canonical_digest"] + } + ], + "suspension_interval_seconds": 300, + "optimistic_concurrency": true + } + }, + "supported_protocols": ["media_buy"], + "experimental_features": ["protocol.principal", "media_buy.reporting_delivery"] +} +``` + +The agent reads three things: + +- **`supported_sections`** lists the sections StreamHaus accepts. An unsupported section is rejected, not ignored. +- **`reporting_destination_offerings`** is StreamHaus's objective offering. The agent picks a pattern, transport, format, and verification profile from it *before* submitting, so an out-of-offering destination is never discovered through a rejection. The account-level reporting offerings used in step 5 are advertised separately under `media_buy.reporting_delivery`. +- **`suspension_interval_seconds`** is the longest StreamHaus may take, after a suspension or deactivation, to stop new deliveries and webhook fires. + +## 3. Verify who StreamHaus thinks you are + +Before changing anything, the agent calls [`get_principal`](/dist/docs/3.3.0-beta.3/protocol/get_principal). It carries no parameters and has no side effects; it never creates a principal record or issues an identifier. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/get-principal-response.json", + "status": "completed", + "result": { + "kind": "recognized", + "principal_kind": "buyer_agent", + "principal_id": "prin_01K4C6RGT5Q18VCPGXE7DDWQ5F" + } +} +``` + +`recognized` means the credential maps to a durable principal that already exists and has no standing configuration. `principal_kind` is deprecated in 3.3 and always `buyer_agent` for a bound agent, so don't branch on it. A seller that does not pre-create records returns `unconfigured` instead, and the first sync creates the record. A `failed` result carries only errors, never identifiers. + +## 4. Sync declarations, webhooks, and a reusable destination + +The agent sends one request. Each section is the complete desired state for that section, omitted sections are untouched, and all sections apply together or not at all. + +- `declarations` states what the buyer agent can consume: which async payload versions, which webhook signing algorithms it can verify, which experimental features it opts into. The seller returns the accepted intersection. +- `notification_configs` registers a webhook subscriber. One subscriber can carry capability and principal changes, and `principal.changed` is the doorbell for seller-driven transitions such as a destination becoming `ready`. +- `reporting_destinations` registers a reusable, non-secret destination. It holds coordinates only: credentials, private keys, and signed URLs never transit AdCP. + +**Schema:** [`sync-principal-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/sync-principal-request.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/sync-principal-request.json", + "idempotency_key": "5b6a3d1e-0c8f-4e49-9a2f-71c2f0d4b8a3", + "configuration": { + "declarations": { + "async_adcp_versions": ["3.2"], + "webhook_signing_algorithms": ["ed25519"], + "experimental_features": ["protocol.principal"] + }, + "notification_configs": [ + { + "subscriber_id": "pinnacle-events", + "url": "https://agents.pinnacle-agency.example/adcp/webhooks", + "event_types": ["capabilities.changed", "principal.changed"], + "active": true + } + ], + "reporting_destinations": [ + { + "pattern": "file_transfer", + "destination_id": "pinnacle-archive", + "active": true, + "provider": { "domain": "object-store.example" }, + "transport": "s3", + "location": "s3://pinnacle-reporting/adcp/", + "accepted_formats": ["parquet"], + "accepted_verification_profiles": ["manifest_checksums"] + } + ] + } +} +``` + +StreamHaus challenges an active webhook URL **during this call**: the endpoint must answer the seller's signed challenge before the subscriber set is replaced, and a failed challenge fails the whole request, so the receiver must be live first. Destination proof is different: it completes *after* the call, and an active destination cannot become `ready` until it succeeds. To rehearse without side effects, add `"dry_run": true`: the seller validates and reports the would-be action (`would_update`, `would_be_unchanged`, or `would_clear`) without persisting, issuing identifiers, or challenging anything. + +### The proof flow: `action_required` to `ready` + +StreamHaus validates the destination against its offering, issues a `destination_ref`, and returns the applied state. The destination is not usable yet, so its state is `action_required` and the response names the one typed action to take: + +**Schema:** [`sync-principal-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/sync-principal-response.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/sync-principal-response.json", + "status": "completed", + "result": { + "kind": "applied", + "action": "updated", + "dry_run": false, + "principal_id": "prin_01K4C6RGT5Q18VCPGXE7DDWQ5F", + "principal_kind": "buyer_agent", + "configuration_version": "cfg_01K4C6V2N5PC1TQAH9WTT8D2HP", + "configuration": { + "notification_configs": [ + { + "subscriber_id": "pinnacle-events", + "url": "https://agents.pinnacle-agency.example/adcp/webhooks", + "event_types": ["capabilities.changed", "principal.changed"], + "active": true + } + ], + "reporting_destinations": [ + { + "destination_id": "pinnacle-archive", + "destination_ref": "dest_01K4C6T6Q0A9E6Y3N1FQ1T8YKV", + "state": "action_required", + "setup": { "action": "prove_control" }, + "configuration": { + "pattern": "file_transfer", + "destination_id": "pinnacle-archive", + "active": true, + "provider": { "domain": "object-store.example" }, + "transport": "s3", + "location": "s3://pinnacle-reporting/adcp/", + "accepted_formats": ["parquet"], + "accepted_verification_profiles": ["manifest_checksums"] + } + } + ], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"], + "webhook_signing_algorithms": ["ed25519"], + "experimental_features": ["protocol.principal"] + }, + "accepted": { + "async_adcp_versions": ["3.2"], + "webhook_signing_algorithms": ["ed25519"], + "experimental_features": ["protocol.principal"] + }, + "selected_async_adcp_version": "3.2" + } + } + } +} +``` + +The loop from here: + +1. **Do the typed action.** AdCP carries only the action name (`prove_control`, `grant_access`, `accept_share`, or `contact_support`), an optional `setup_url`, and an optional expiry. The details, such as which proof object to write under the bucket prefix, come from StreamHaus's onboarding material or its `setup_url`. Treat `setup_url` as an untrusted link, surface it to Sam, and never fetch or execute it. Credentials stay in the provider's own control plane. +2. **Wait for the doorbell, or poll.** StreamHaus fires `principal.changed` when the destination moves. The agent can also call `get_principal` until the state reaches `ready`; if `setup.expires_at` passes first, resubmit the destination to obtain a fresh action. Reading never advances `configuration_version`, and neither does the seller's own transition, so a read-then-write loop stays safe. +3. **Treat `ready` as the gate.** Only a `ready` destination may be bound to an account feed. `rejected` means StreamHaus cannot use those coordinates and the request needs a different destination. + +If the agent later changes the provider, transport, location, accepted formats, or accepted verification profiles, StreamHaus issues a *new* `destination_ref` and requires fresh proof. The old reference stays resolvable for existing bindings and reporting history and is listed under `prior_destination_refs`. Existing feed bindings do not move on their own: to redirect delivery, bind a new `delivery_config_version` to the new reference once it is `ready`. Resubmitting the identical destination is a no-op: `action: "unchanged"` and the same reference. + +The declarations section returns the negotiated contract. If the agent had declared a version or algorithm StreamHaus does not support, it would be absent from `accepted` and listed under `exclusions` with a reason, and a declared signing algorithm that shares nothing with the seller's would fail the request with `UNSUPPORTED_FEATURE` while a subscriber is active. + +## 5. Authorize accounts and bind the feed + +`ready` proves only where the principal can receive data. It says nothing about *which* data. To get Nova Motors' reporting into the bucket, the agent establishes the account relationship with [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts), as in [Provision a seller-mediated account](/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough), and binds the account's feed to the reusable destination through `reporting_delivery_configs`. The example below is abbreviated: include `timezone`, `payment_terms`, and `billing_entity` as that walkthrough does, and choose `offering_id` and `report_definition_id` from the seller's `media_buy.reporting_delivery` offerings. + +**Schema:** [`sync-accounts-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-request.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-request.json", + "idempotency_key": "e7a40a15-2d54-4a4b-8d0b-3f7a1c9b6d52", + "accounts": [ + { + "brand": { "domain": "novamotors.example" }, + "operator": "pinnacle-agency.example", + "billing": "advertiser", + "reporting_delivery_configs": [ + { + "delivery_config_id": "pinnacle-pacing", + "delivery_config_version": 1, + "offering_id": "pacing-hourly", + "active": true, + "feed_purpose": "pacing", + "report_definition_id": "streamhaus_delivery_summary_v1", + "reporting_profile": "streamhaus_delivery_summary_v1", + "scope": { "all_media_buys": true }, + "coverage_requirement": "full", + "required_finality": "snapshot", + "reconciliation_mode": "delivery_only", + "schedule": { + "period_duration": "PT1H", + "alignment": "utc", + "delivery_sla": "PT1H" + }, + "method": { + "pattern": "file_transfer", + "transport": "s3", + "orchestration": "producer_managed", + "format": "parquet", + "destination": { + "mode": "existing", + "destination_ref": "dest_01K4C6T6Q0A9E6Y3N1FQ1T8YKV" + } + } + } + ] + } + ] +} +``` + +Two independent checks gate this binding: + +- **The destination.** StreamHaus resolves `destination_ref` only for the principal that registered it, and a new binding requires it to be `ready`. An unknown, cross-principal, or unauthorized reference fails indistinguishably. +- **The account.** StreamHaus authorizes the disclosure of Nova Motors' feed to this principal for this scope. Possessing a `ready` reference is never account authority, and the account check is independent of the destination's state. + +The same destination can serve several accounts, each bound and authorized separately. A binding can still wait on account setup or provider readiness; observe it through [`get_reporting_status`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_reporting_status). Reliable reporting is its own experimental surface (`media_buy.reporting_delivery`); see [experimental status](/dist/docs/3.3.0-beta.3/reference/experimental-status). + +## 6. Read it back, and know the kill switch + +[`get_principal`](/dist/docs/3.3.0-beta.3/protocol/get_principal) is the durable answer to "what does StreamHaus think my standing configuration is?". The agent uses it to bootstrap `expected_configuration_version` before a guarded replacement, to recover after a restart, and to observe setup transitions. + +**Schema:** [`get-principal-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/get-principal-response.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/get-principal-response.json", + "status": "completed", + "result": { + "kind": "current", + "principal_id": "prin_01K4C6RGT5Q18VCPGXE7DDWQ5F", + "principal_kind": "buyer_agent", + "configuration_version": "cfg_01K4C6V2N5PC1TQAH9WTT8D2HP", + "configuration": { + "notification_configs": [ + { + "subscriber_id": "pinnacle-events", + "url": "https://agents.pinnacle-agency.example/adcp/webhooks", + "event_types": ["capabilities.changed", "principal.changed"], + "active": true + } + ], + "reporting_destinations": [ + { + "destination_id": "pinnacle-archive", + "destination_ref": "dest_01K4C6T6Q0A9E6Y3N1FQ1T8YKV", + "state": "ready", + "configuration": { + "pattern": "file_transfer", + "destination_id": "pinnacle-archive", + "active": true, + "provider": { "domain": "object-store.example" }, + "transport": "s3", + "location": "s3://pinnacle-reporting/adcp/", + "accepted_formats": ["parquet"], + "accepted_verification_profiles": ["manifest_checksums"] + } + } + ], + "declarations": { + "declared": { + "async_adcp_versions": ["3.2"], + "webhook_signing_algorithms": ["ed25519"], + "experimental_features": ["protocol.principal"] + }, + "accepted": { + "async_adcp_versions": ["3.2"], + "webhook_signing_algorithms": ["ed25519"], + "experimental_features": ["protocol.principal"] + }, + "selected_async_adcp_version": "3.2" + } + } + } +} +``` + +Pass `configuration_version` back as `expected_configuration_version` on a guarded `sync_principal`. If two services share one principal and one is stale, StreamHaus rejects the stale write with `CONFLICT` and changes nothing; read again, reconcile, and resubmit under a **new** `idempotency_key`. Retries of one logical request reuse both the key and the body. + +### The kill switch + +These controls stop delivery on the caller's own principal. They are only as trustworthy as the credential that drives them: if a credential is compromised, revoke it in the seller's authorization system, because anyone holding it can reverse these settings. + +- **Pause a webhook.** Resubmit the subscriber with `active: false`. Fires to that endpoint, across every account, halt within `suspension_interval_seconds`. +- **Suspend a destination.** Resubmit the destination with `active: false`. New deliveries to *every generation* of that `destination_id` halt within the same bound, and every bound account is marked with a typed `destination_suspended` issue. Resubmitting with `active: true` lifts it without new proof when nothing else changed. +- **Revoke a destination.** Omit it from `reporting_destinations`, or send `[]` for all. Revocation has every suspension effect, removes the destination from current state, and keeps its generations under `retired_destinations` for audit. Reusing the `destination_id` later needs fresh registration and proof. + +New deliveries may continue until the advertised interval elapses, and none of these delete data already delivered or revoke grants managed in the provider's control plane. Narrower controls exist per account (deactivating a `reporting_delivery_configs` entry, or changing account authority through `sync_accounts`), and per-buy `reporting_webhook` and account-level subscribers are independent of the principal-level subscriber. + +### The `principal.changed` webhook + +StreamHaus tells the agent's subscriber when its own transitions change the principal state. The payload is an invalidation signal, not state: identifiers and a repair pointer only. The receiver verifies the webhook signature, dedupes on `idempotency_key`, and repairs by calling `get_principal`. + +**Schema:** [`principal-changed-webhook.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/principal-changed-webhook.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/principal-changed-webhook.json", + "idempotency_key": "9f2c1e57-3f6a-4f4e-9f0d-2a45b7c6e881", + "notification_id": "conn_txn_01K4D0Z3M8Q0V5T2C9XWJ7R4BA", + "notification_type": "principal.changed", + "fired_at": "2026-09-01T12:00:05Z", + "subscriber_id": "pinnacle-events", + "agent_url": "https://sales.streamhaus.example/adcp", + "changed_at": "2026-09-01T12:00:03Z", + "reason": "destination_state_changed", + "destination_id": "pinnacle-archive" +} +``` + +The `$schema` key above is documentation metadata, not part of the payload. The webhook is not fired for the agent's own `sync_principal` mutations, since the sync response already reports those. Webhooks can be dropped, so a receiver also polls `get_principal` on a bounded schedule rather than relying on the doorbell alone. + +## Run it against the training agent + +The public training agent implements `sync_principal` and `get_principal` on its sales endpoint, `https://test-agent.adcontextprotocol.org/sales/mcp` (see [Build a caller](/dist/docs/3.3.0-beta.3/building/by-layer/L4/build-a-caller) for connecting). Calls need a credential: anonymous calls get `AUTH_REQUIRED`. It is a reference seller, so it differs from StreamHaus: + +- It advertises the `reporting_destinations` and `declarations` sections. Webhook subscribers are still registered through [`sync_agent_notification_configs`](/dist/docs/3.3.0-beta.3/protocol/sync_agent_notification_configs), so drop `notification_configs` from the step 4 request. +- Destination proof is simulated. An active destination reads back `action_required` with `setup.action: prove_control` (also for warehouse patterns, where a real seller would ask for `grant_access`), then `ready` a few seconds later. No bucket is contacted and no `principal.changed` webhook fires. +- It offers `file_transfer` (`s3`, `gcs`) and `warehouse_materialization` (`bigquery`, `snowflake`). Read its `adcp.principal` block for the exact offering. +- State is keyed to the credential you authenticate with, and everyone using the shared public test token is the same principal. Treat anything you register there as public, and expect `current` rather than `recognized` if someone else has already configured it. + +The runnable subset of step 4. Use your own `destination_id` so the sequence is reproducible on a shared token: + +**Schema:** [`sync-principal-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/sync-principal-request.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/sync-principal-request.json", + "idempotency_key": "1d0b7c44-6b0e-4f0b-8a43-9d3c5a2f7e10", + "configuration": { + "declarations": { + "async_adcp_versions": ["3.2"], + "webhook_signing_algorithms": ["ed25519"] + }, + "reporting_destinations": [ + { + "pattern": "file_transfer", + "destination_id": "pinnacle-archive-demo", + "active": true, + "provider": { "domain": "object-store.example" }, + "transport": "s3", + "location": "s3://pinnacle-reporting/adcp/", + "accepted_formats": ["parquet"], + "accepted_verification_profiles": ["manifest_checksums"] + } + ] + } +} +``` + +Call `get_principal` first, send this request (expect `applied` with `action_required`), then poll `get_principal` until the destination is `ready`. Send the same body under a fresh `idempotency_key` to see `action: "unchanged"` with the same `destination_ref`. Clear your destination by resubmitting the section without it; avoid `[]` on a shared token unless you registered everything there. The [`principal` storyboard](/dist/docs/3.3.0-beta.3/building/verification/conformance) covers the recognized read, apply and readback, idempotent replay, and section clearing; it applies a suspended destination, so it does not exercise the proof transition. + +## Failure branches + +The full error table is in the [`sync_principal` reference](/dist/docs/3.3.0-beta.3/protocol/sync_principal#errors). The ones a buyer agent meets first: + +- **`AUTH_REQUIRED`.** The call carried no stable principal. Authenticate with a credential the seller maps to one. +- **`UNSUPPORTED_FEATURE`.** A section, destination, or event type is outside what the seller advertised. Re-read capabilities and choose from the offering. +- **`CONFLICT`.** `expected_configuration_version` did not match. Nothing changed; read with `get_principal`, reconcile, and resubmit under a new key. +- **A destination stays `action_required`.** The typed action is incomplete or unverified. Complete it in the provider and poll; resubmit only if `setup.expires_at` has passed. +- **A destination is `rejected`.** Those coordinates cannot be used. Register a different destination rather than retrying. +- **The feed binding is refused.** The destination is not `ready`, or the account relationship does not authorize the feed. Fix whichever side failed; one does not substitute for the other. + +## Related reference + +- [`sync_principal`](/dist/docs/3.3.0-beta.3/protocol/sync_principal) and [`get_principal`](/dist/docs/3.3.0-beta.3/protocol/get_principal) — request, response, and error reference +- [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) — the `adcp.principal` block +- [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) — account authorization and `reporting_delivery_configs` +- [Provision a seller-mediated account](/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough) — the related account walkthrough +- [`sync_agent_notification_configs`](/dist/docs/3.3.0-beta.3/protocol/sync_agent_notification_configs) — the specialized webhook registration task diff --git a/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough.mdx b/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough.mdx new file mode 100644 index 00000000000..fae770a6d6c --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough.mdx @@ -0,0 +1,393 @@ +--- +title: Provision a seller-mediated account +sidebarTitle: Provision an account +description: "Follow an AdCP buyer-declared account from capability discovery through human setup, activation, and its first media buy." +"og:title": "AdCP — Provision a seller-mediated account" +testable: true +--- + + +This walkthrough uses [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts), which stays stable and available in 3.3. With a seller that advertises `account.requests`, the same flow uses [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) to open the account and [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) to read it and its request back. + + +This walkthrough follows one buyer-declared account from discovery to its first media buy. It focuses on the case where a seller provisions the relationship but a human must finish credit or legal setup before the account becomes active. + +## Scenario + +Tomoko at Nova Motors has asked Sam's Pinnacle Agency buyer agent to buy inventory from Priya's StreamHaus sales agent. Nova Motors is the advertiser, Pinnacle Agency is the operator, and Nova Motors should receive the invoice. + +The StreamHaus account does not exist yet. Sam must discover the seller's account model, request provisioning, give Tomoko the setup link, observe activation, reconcile the webhook against the seller's current state, and only then create a media buy. + +## Before you provision + +This flow applies only when [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) returns `account.require_operator_auth: false`. In that model, the seller trusts the authenticated buyer agent to declare a brand and operator through [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). + +If `require_operator_auth` is `true`, stop here. The operator authenticates directly and the buyer discovers a seller-assigned `account_id` through [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) or receives it during out-of-band onboarding. Do not attempt natural-key provisioning unless a future capability explicitly declares it. + +Before sending the request, Sam also checks: + +- `account.supported_billing` includes the desired `advertiser` value. +- `account.notifications.supported` is `true` and the declared event types include `account.status_changed` if the buyer wants durable lifecycle notifications. +- Nova Motors' brand registry authorizes `pinnacle-agency.example` to operate for the brand. +- The authenticated buyer agent is allowed to establish this brand/operator relationship. + +When durable account notifications are unavailable, the buyer may still use the one-shot `sync_accounts.push_notification_config` callback for the original provisioning task and poll `list_accounts` for later status changes. + +## End-to-end sequence + +```mermaid +sequenceDiagram + autonumber + actor Sam + participant Buyer as Pinnacle buyer agent + participant Seller as StreamHaus sales agent + actor Tomoko + participant Governance as Governance agent + + Buyer->>Seller: get_adcp_capabilities + Seller-->>Buyer: require_operator_auth: false
supported_billing + notifications + + Buyer->>Seller: sync_accounts
brand + operator + billing
notification_configs + optional one-shot callback + Seller->>Seller: Validate agent identity, operator authorization,
billing model, and payment terms + Seller-->>Buyer: pending_approval + account_id + setup + + Buyer-->>Tomoko: Share setup.url before expires_at + Tomoko->>Seller: Complete credit and legal setup + + Seller-->>Buyer: account.status_changed
pending_approval → active + Buyer->>Seller: list_accounts(account_id) + Seller-->>Buyer: Authoritative active account snapshot + + opt Governance plan configured + Buyer->>Governance: check_governance intent
including optional invoice_recipient + Governance-->>Buyer: approved + governance_context + end + Buyer->>Seller: create_media_buy
account_id AccountRef + optional invoice_recipient
+ governance_context when required + opt Online execution check applies + Seller->>Governance: check_governance
governance_context + planned_delivery
+ invoice_recipient when present + Governance-->>Seller: Decision + end + Seller-->>Buyer: Media buy result +``` + +## Discover the account model + +Sam calls `get_adcp_capabilities` before choosing an account workflow. The relevant capability fragment looks like this: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/get-adcp-capabilities-response.json", + "status": "completed", + "adcp": { + "major_versions": [3], + "supported_versions": ["3.1", "3.2"], + "idempotency": { + "supported": true, + "replay_ttl_seconds": 86400 + } + }, + "supported_protocols": ["media_buy"], + "account": { + "require_operator_auth": false, + "supported_billing": ["operator", "agent", "advertiser"], + "supported_account_currency_modes": ["fixed", "per_media_buy"], + "timezone": { + "mode": "account_fixed", + "account_selection": "buyer_selected", + "supported_timezones": ["Europe/London", "UTC"] + }, + "notifications": { + "supported": true, + "registration_task": "sync_accounts", + "read_task": "list_accounts", + "event_types": ["account.status_changed"], + "supports_webhook_activity": true + } + } +} +``` + +`require_operator_auth: false` selects buyer-declared provisioning. `supported_billing` tells Sam which invoiced parties the seller accepts at the capability level; it does not guarantee that every authenticated buyer agent is commercially authorized for every advertised value. `supported_account_currency_modes` tells Sam whether to include one account `currency`, omit it for per-media-buy selection, or choose either model. The timezone capability requires Sam to choose one advertised account timezone when the account opens. + +The `notifications` block selects the preferred observation strategy: register a durable subscriber during provisioning, treat each webhook as an invalidation signal, and repair from `list_accounts`. If the block is absent or `supported` is `false`, poll instead. + +## Request provisioning + +Sam sends a fresh `idempotency_key`, the Nova Motors brand, Pinnacle Agency as operator, the advertised billing model, and the requested payment terms. The same account entry registers a durable lifecycle subscriber. The top-level `push_notification_config` is optional and applies only to the async result of this provisioning task; it does not replace the durable subscriber. + +{/* account.status_changed is a next-minor surface; the stable v3 alias resolves to it when that release becomes current. */} + +**Schema:** [`sync-accounts-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-request.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-request.json", + "idempotency_key": "2fc44b7a-6c1c-4cb9-83da-bdcce1b6c0f8", + "accounts": [ + { + "brand": { + "domain": "novamotors.example" + }, + "operator": "pinnacle-agency.example", + "timezone": "Europe/London", + "billing": "advertiser", + "billing_entity": { + "legal_name": "Nova Motors Ltd.", + "registration_number": "NM-2026-1042", + "address": { + "street": "12 Aurora Road", + "city": "London", + "postal_code": "SW1A 1AA", + "country": "GB" + }, + "contacts": [ + { + "role": "billing", + "name": "Tomoko", + "email": "accounts-payable@novamotors.example" + } + ] + }, + "payment_terms": "net_30", + "notification_configs": [ + { + "subscriber_id": "account-lifecycle", + "url": "https://buyer.pinnacle-agency.example/webhooks/adcp/accounts", + "event_types": ["account.status_changed"], + "active": true + } + ] + } + ], + "push_notification_config": { + "url": "https://buyer.pinnacle-agency.example/webhooks/adcp/tasks", + "operation_id": "provision-nova-streamhaus-2026-08-12" + } +} +``` + +Priya's seller validates the authenticated agent identity and its authority to represent the operator and brand. It either accepts the billing model and payment terms exactly as requested or rejects the account entry; it must not silently substitute a different payer or terms. + +The seller also validates the durable endpoint and completes its proof-of-control challenge before marking the subscriber active. It assigns an `account_id` before activation so later account lifecycle events can always identify an authoritative repair target, even while approval remains pending. StreamHaus creates every account it approves, so the placeholder's `account_id` is the one the account keeps, and issuing it early is safe. A seller that might instead link the request to an existing account never issues an `account_id` it might later change; it keeps the request as an [account request](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) with no `account_id` until it resolves (experimental in 3.3). + +The account needs a human credit and legal review, so StreamHaus returns `pending_approval`: + +**Schema:** [`sync-accounts-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-response.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-response.json", + "status": "completed", + "accounts": [ + { + "account_id": "acct_streamhaus_nova_pinnacle", + "name": "Nova Motors c/o Pinnacle Agency", + "brand": { + "domain": "novamotors.example" + }, + "operator": "pinnacle-agency.example", + "timezone": "Europe/London", + "action": "created", + "status": "pending_approval", + "billing": "advertiser", + "billing_entity": { + "legal_name": "Nova Motors Ltd.", + "registration_number": "NM-2026-1042", + "address": { + "street": "12 Aurora Road", + "city": "London", + "postal_code": "SW1A 1AA", + "country": "GB" + }, + "contacts": [ + { + "role": "billing", + "name": "Tomoko", + "email": "accounts-payable@novamotors.example" + } + ] + }, + "payment_terms": "net_30", + "account_scope": "operator_brand", + "setup": { + "url": "https://onboarding.streamhaus.example/accounts/acct_streamhaus_nova_pinnacle", + "message": "Complete the StreamHaus credit application and media terms.", + "expires_at": "2026-08-19T17:00:00Z" + }, + "notification_configs": [ + { + "subscriber_id": "account-lifecycle", + "url": "https://buyer.pinnacle-agency.example/webhooks/adcp/accounts", + "event_types": ["account.status_changed"], + "active": true + } + ] + } + ] +} +``` + +The response carries the seller's `account_id`, which identifies the account from now on. Sam stores it and uses it for webhook correlation, `list_accounts` repair, and every later account-scoped call. + +If the request included bank details, the response must not echo `billing_entity.bank`; bank fields are write-only. + +## Complete human setup + +Sam presents `setup.message` to Tomoko and shares `setup.url` before `setup.expires_at`. The URL is optional in the protocol, so an implementation must also handle a message-only response by directing the human to the seller's established support or onboarding channel. + +Tomoko follows the StreamHaus URL, completes the credit application, and accepts the media terms. Sam must not send a media buy while the authoritative account status remains `pending_approval`. + +Setup URLs can expire or be single-use. If one is missing, expired, or already consumed, Sam re-reads `list_accounts` for current instructions rather than relying on an old response or webhook payload. + +## Observe and reconcile resolution + +After approval, StreamHaus sends the durable subscriber a complete `account.status_changed` event. The receiver verifies the webhook signature, scopes deduplication to the authenticated sender, and deduplicates retries by `idempotency_key`. + +**Schema:** [`account-status-changed-webhook.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/account-status-changed-webhook.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/account-status-changed-webhook.json", + "idempotency_key": "whk_01J5C8PX4W6EJYNRDT30M0VN4A", + "notification_id": "acctchg_streamhaus_nova_20260812T143000Z", + "notification_type": "account.status_changed", + "fired_at": "2026-08-12T14:30:03Z", + "subscriber_id": "account-lifecycle", + "account_id": "acct_streamhaus_nova_pinnacle", + "previous_status": "pending_approval", + "status": "active", + "observed_at": "2026-08-12T14:30:00Z", + "reason_code": "seller_approved" +} +``` + +The event's `status` and `reason_code` are advisory. It deliberately omits the complete account and any `setup.url`. Sam immediately calls `list_accounts` with the echoed `account_id`; that authenticated read is the source of truth. + +**Request:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/list-accounts-request.json", + "account": { + "account_id": "acct_streamhaus_nova_pinnacle" + } +} +``` + +**Schema:** [`list-accounts-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/list-accounts-response.json) + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/list-accounts-response.json", + "status": "completed", + "accounts": [ + { + "account_id": "acct_streamhaus_nova_pinnacle", + "name": "Nova Motors c/o Pinnacle Agency", + "brand": { + "domain": "novamotors.example" + }, + "operator": "pinnacle-agency.example", + "status": "active", + "billing": "advertiser", + "billing_entity": { + "legal_name": "Nova Motors Ltd.", + "registration_number": "NM-2026-1042", + "address": { + "street": "12 Aurora Road", + "city": "London", + "postal_code": "SW1A 1AA", + "country": "GB" + }, + "contacts": [ + { + "role": "billing", + "name": "Tomoko", + "email": "accounts-payable@novamotors.example" + } + ] + }, + "payment_terms": "net_30", + "account_scope": "operator_brand", + "notification_configs": [ + { + "subscriber_id": "account-lifecycle", + "url": "https://buyer.pinnacle-agency.example/webhooks/adcp/accounts", + "event_types": ["account.status_changed"], + "active": true + } + ] + } + ] +} +``` + +If durable notifications are unavailable, Sam polls `list_accounts` by the known account reference until it returns `active`, `rejected`, or another state that requires action. The same repair read should run periodically even with webhooks so missed, delayed, or distrusted fires cannot leave local state stale. + +## Use the active account + +Once the authoritative snapshot is `active`, Sam can call [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy). The request references the account by its `account_id`: + +The example below assumes no governance plan is configured. When governance applies, Sam first sends the full intended payload, including any `invoice_recipient`, to `check_governance`. Only an `approved` decision yields the `governance_context` that Sam attaches to `create_media_buy`; `conditions` requires an adjusted intent re-check, and `denied` stops the buy. A seller that declares online execution checks then validates its `planned_delivery` with the same context before committing, forwarding `invoice_recipient` as the execution check's separate top-level field when the buy supplied one. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/media-buy/create-media-buy-request.json", + "idempotency_key": "a9f52ca1-346b-40f3-8bad-0b96381470bc", + "account": { + "account_id": "acct_streamhaus_nova_pinnacle" + }, + "brand": { + "domain": "novamotors.example" + }, + "start_time": "2026-09-01T00:00:00Z", + "end_time": "2026-09-30T23:59:59Z", + "total_budget": { + "amount": 50000, + "currency": "GBP" + }, + "packages": [ + { + "product_id": "streamhaus-connected-tv", + "pricing_option_id": "cpm-standard", + "budget": 50000 + } + ] +} +``` + +An optional `invoice_recipient` can override the account's default billing entity for this media buy. The seller validates that the caller is authorized to use the recipient. When governance agents are configured, the seller includes the override in [`check_governance`](/dist/docs/3.3.0-beta.3/governance/campaign/tasks/check_governance) so the billing redirect can be approved or rejected. See [Billing entity and invoice recipient](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient). + +## Obligations by party + +| Stage | Buyer agent | Seller agent | Human | +|---|---|---|---| +| Discovery | Read `require_operator_auth`, choose a value advertised in `supported_billing`, and select webhook or polling observation. | Accurately declare the supported account model, billing values, and durable notification capability. | — | +| Provisioning | Send a fresh `idempotency_key`, brand, operator, billing choice, and any requested terms. Do not send the settings-update `account` field in the same entry. | Authenticate the agent; validate brand/operator authority, billing permission, terms, and webhook control. Accept exact billing and terms or reject them without remapping. | — | +| Pending setup | Surface `setup.message` and any current URL and expiry. Do not spend yet. | Return `setup.message` for required action and, when available, a URL and expiry. Never expose write-only bank data. | Complete the seller's credit, legal, or payment steps. | +| Resolution | Deduplicate the fire, treat it as advisory, and repair through `list_accounts`. Poll when durable notifications are unavailable. | Fire `account.status_changed` to active subscribers and make the authoritative state available through `list_accounts`. | Respond to renewed setup instructions if the account is not active. | +| Media buy | Reference the account by `account_id`; send an override only when authorized. | Permit new spend only for an active account. Validate `invoice_recipient` and pass it to governance when configured. | Approve any campaign-specific billing override required by local policy. | + +## Failure branches + +- **The seller requires operator authentication.** Do not use this provisioning flow. Authenticate the operator and resolve a seller-assigned `account_id` through `list_accounts` or out-of-band onboarding. +- **The billing value is not advertised.** Choose a supported value with the invoiced party's approval or stop for commercial onboarding. The seller returns `BILLING_NOT_SUPPORTED` for a seller-wide mismatch. +- **The buyer agent lacks permission for an advertised billing value.** The seller rejects with the narrower authenticated-agent billing error, such as `BILLING_NOT_PERMITTED_FOR_AGENT`. When `error.details.suggested_billing` is present, the buyer may make one autonomous retry with exactly that value. If no suggestion is present or that retry is also rejected, stop and require human onboarding; do not keep changing who owes money. +- **Payment terms are unacceptable.** The seller rejects the account entry. It must not silently change `net_30` to another term. +- **Brand or operator authorization fails.** Correct the brand registry or authenticated relationship before retrying. Reuse the same idempotency key only when retrying the same logical request after an ambiguous transport outcome; use a new key for a materially changed request. +- **Webhook validation or activation proof fails.** The seller rejects the account entry and leaves the previous subscriber set unchanged. Correct the endpoint, then retry provisioning safely. +- **The account is rejected.** Read the explanation from `warnings[]`; there is no `account.reason` field. Escalate to a human or submit a materially corrected request with a new idempotency key. +- **The setup link is absent or expired.** Fetch the current account through authenticated `list_accounts`. The lifecycle webhook never carries `setup.url`. +- **No durable notification arrives.** Poll `list_accounts`. The one-shot task callback covers only the original provisioning result and is not a lifecycle subscription. +- **The repair read is not active.** Honor the returned state. `pending_approval`, `payment_required`, `suspended`, `rejected`, and `closed` do not permit a new media buy. +- **Billing must change after provisioning.** Send `billing` in a settings-update entry that names the account by `account_id`. A seller that cannot change the invoiced party returns `UNSUPPORTED_FEATURE`; then follow the seller's commercial process. +- **A per-buy invoice override is rejected.** If the seller does not authorize `invoice_recipient`, or governance denies the override, the seller rejects the media buy. The buyer may use the already-authorized account default after obtaining any required fresh governance approval, or escalate to a human; it must not silently reroute the invoice. + +## Related reference + +- [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) — account model, supported billing, and durable-notification discovery +- [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) — provisioning fields, setup results, and account-level subscriptions +- [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) — authoritative state and webhook repair +- [Accounts and agents](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents) — account references, billing roles, and authorization models +- [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy) — the first spend operation after activation +- [`check_governance`](/dist/docs/3.3.0-beta.3/governance/campaign/tasks/check_governance) — governance review of campaign-specific invoice recipients diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/get_account_financials.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/get_account_financials.mdx new file mode 100644 index 00000000000..53e07c13728 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/get_account_financials.mdx @@ -0,0 +1,257 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/account/get-account-financials-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/get-account-financials-request.json) +**Response Schema**: [`/schemas/3.3.0-beta.3/account/get-account-financials-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/get-account-financials-response.json) + +## Quick start + +Check remaining credit before launching a campaign: + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; + +const result = await testAgent.getAccountFinancials({ + account: { account_id: "acc_acme_001" }, +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +if ("errors" in result.data && result.data.errors) { + throw new Error(`Operation failed: ${JSON.stringify(result.data.errors)}`); +} + +const { spend, credit, payment_status } = result.data; +console.log(`Spent: $${spend?.total_spend} this period`); + +if (credit) { + console.log(`Available credit: $${credit.available_credit} of $${credit.credit_limit}`); +} + +if (payment_status === "past_due") { + console.log("Warning: payment is past due — campaigns may be paused"); +} +``` + +```python Python +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.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references) — by the `account_id` from [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) or [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts). During 3.x, a natural key (`brand` + `operator` + optional `sandbox`) that matches exactly one account also works. 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. It may differ from `Account.timezone`, reporting timezone, or a feature-specific cap timezone; use this explicit value for financial periods. | +| `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`. Sellers may limit the list, except that a seller listing `invoice` in `account.change_feed.resource_types` MUST return every invoice with a retained change record, as defined in [Invoices](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes#invoices). Experimental in 3.3 (`account.invoice_lines`): optional `lines[]`, `invoice_kind` (`invoice` or `credit_note`), and `adjusts_invoice_id`; see [Invoice lines](#invoice-lines-experimental). | + +**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. + +### Being notified of new invoices + +Sellers that list `invoice` in `account.change_feed.resource_types` record a change when an invoice is created, changes status, or has its amount or period corrected. Subscribe to `account.change_recorded` and drain [`list_account_changes`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes#invoices) instead of polling; each record carries the `invoice_id` as `resource.resource_id`, and the buyer rereads this task to get the invoice. The feed is experimental. Buyers of sellers without it must keep polling. + +## Common scenarios + +### Budget check before campaign launch + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; + +const financials = await testAgent.getAccountFinancials({ + account: { account_id: "acc_acme_001" }, +}); + +if (!financials.success || "errors" in financials.data) { + throw new Error("Could not check financials"); +} + +const campaignBudget = 15000; +const { credit } = financials.data; + +if (credit && credit.available_credit < campaignBudget) { + console.log( + `Insufficient credit: $${credit.available_credit} available, ` + + `$${campaignBudget} needed. ` + + `Credit utilization: ${credit.utilization_percent}%` + ); +} else { + console.log("Budget check passed — proceeding with campaign"); +} +``` + +```python Python +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/sdk/testing"; + +const financials = await testAgent.getAccountFinancials({ + account: { + brand: { domain: "acme-corp.com" }, + operator: "acme-corp.com", + }, +}); + +if (!financials.success || "errors" in financials.data) { + throw new Error("Could not check financials"); +} + +const { balance } = financials.data; +if (balance && balance.available < 2000) { + console.log( + `Low balance warning: $${balance.available} remaining. ` + + `Consider topping up before launching new campaigns.` + ); +} +``` + +```python Python +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()) +``` + + + +### Invoice lines (experimental) + + +Experimental in AdCP 3.3 under the `account.invoice_lines` feature id. A seller that returns `lines`, `invoice_kind`, or `adjusts_invoice_id` MUST list `account.invoice_lines` in `experimental_features` on [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities). See [Experimental status](/dist/docs/3.3.0-beta.3/reference/experimental-status). + + +An official reporting revision is the invoice-addressable close for its reporting slice, but an invoice that carries only `invoice_id` and `amount` cannot say which of that reporting it billed. Sellers that itemise add optional fields to each `invoices[]` item: + +| Field | Description | +|-------|-------------| +| `lines[]` | Itemised lines. Each has a required `media_buy_id` and optional `package_id`, `reporting_revision_ids[]` (official revisions the line bills from), `reporting_adjustment_ids[]` (adjustments the line applies, for corrections billed in a later period), `billed_quantity` (the `core/reporting-control-total.json` value and unit shape), and `amount`. | +| `invoice_kind` | `invoice` (default) or `credit_note`. | +| `adjusts_invoice_id` | For a credit note or rebill, the `invoice_id` it changes. | + +Amounts are never negative. A credit note is `invoice_kind: "credit_note"` with a non-negative `amount`; the kind carries the sign, and a buyer subtracts it from what is owed. A credit note has no `due_date`; its `paid_date`, when present, is the date the credit was applied or refunded. A document is one direction: a net-negative correction goes on a `credit_note`, never on an `invoice`, and line `billed_quantity` values are non-negative. Sellers MUST NOT send `credit_note` records to callers pinned below AdCP 3.3, because a legacy buyer summing `invoices[].amount` would count the credit as a charge. A rebill is a new `invoice` that carries `adjusts_invoice_id`; the replaced invoice is reported `void` or offset by a credit note, so buyers do not count both. `reporting_revision_ids` and `reporting_adjustment_ids` are the opaque ids from the `media_buy.reporting_delivery` surface. + +Lines are reference only and never settle. They identify which reporting an invoice amount covers; they do not create, approve, or settle invoices, calculate tax or FX, or determine payment obligations, which stays inside the boundary set by `core/reporting-adjustment.json` and `enums/billing-party.json`. The sum of line amounts SHOULD NOT exceed the invoice `amount`; taxes, fees, and unitemised charges may sit outside the lines. Sellers that do not itemise keep returning invoices without `lines`. + +Buyers read `lines` through `get_account_financials`, including as the repair read for an invoice change on the [account change feed](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes). The line shape is the standalone `core/invoice-line.json`, so a later invoice notification can carry the same `lines` without changing shape. + +## Error handling + +| Error Code | Description | Resolution | +|------------|-------------|------------| +| [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found) | Account does not exist or is not accessible | Check account reference or re-sync | + +## Next steps + +- [list_accounts](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) -- Discover accounts and check their status +- [get_adcp_capabilities](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) -- Check `account_financials` before calling this task +- [Accounts and agents](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents) -- Billing models and account references diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes.mdx new file mode 100644 index 00000000000..4aa6ea22a15 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes.mdx @@ -0,0 +1,311 @@ +--- +title: list_account_changes +description: "list_account_changes reads the durable ordered feed of material changes to authoritative state on one shared advertiser account." +"og:title": "AdCP — list_account_changes" +testable: false +--- + +`list_account_changes` returns material changes to AdCP-visible state on one +account, including changes made outside the observing buyer's calls. Use it +after a snapshot bootstrap, after receiving `account.change_recorded`, or for +an account-level audit of state transitions. + + +**Experimental.** The account change feed (`list_account_changes`, the `account.change_recorded` webhook, the `account.change_feed` capability block on [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities), and the [`CURSOR_EXPIRED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-cursor-expired) error code) is part of AdCP 3.2 as an experimental surface. [RFC #6810](https://github.com/adcontextprotocol/adcp/issues/6810) is still open, so the surface may change between 3.x releases with at least 6 weeks' notice. Release 3.3 adds the `idempotency_key` echo and requires `changed_paths` for `adcp` and `seller_operator` records, both described below. Sellers implementing any part of it MUST declare `account.change_feed` in `experimental_features`. See [experimental status](/dist/docs/3.3.0-beta.3/reference/experimental-status) for the full contract. + + +The feed is optional and appears only when +`get_adcp_capabilities.account.change_feed.supported` is `true` and +`experimental_features` contains `account.change_feed`. + +## What the feed covers + +For every resource type the seller advertises, a material committed change +must both appear here and be reflected on its authoritative read, regardless +of whether it originated through AdCP, a seller operator or system, another +authorized principal, or a connected platform. + +The feed describes control-plane changes: creation, configuration, lifecycle, +relationships, reporting corrections, and deletion markers. It excludes +reads, failed operations, dry runs, exact idempotency replays, delivery counter +increments, raw audience members, raw events, catalog item bodies, and webhook +delivery attempts. + +For the complete coverage matrix and rationale, see the +[account change feed design spec](https://github.com/adcontextprotocol/adcp/blob/main/specs/account-change-feed.md). + +## Request + +**Request schema:** [`account/list-account-changes-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/list-account-changes-request.json) + +| Field | Required | Description | +| --- | --- | --- | +| `account` | Yes | Exactly one account reference. | +| `cursor` | No | Opaque checkpoint from an earlier call under the same principal, authorization epoch, account, and filters. Mutually exclusive with `starting_position`. | +| `starting_position` | No | `earliest` (intentional completeness-first default) or `latest`. Use `latest` before a snapshot bootstrap. | +| `resource_types` | No | Exact resource-type filter. The cursor is bound to the normalized filter. | +| `max_results` | No | 1–100 changes; default 50. | + +Acquire a high-water checkpoint before reading several snapshots: + +```json +{ + "adcp_version": "3.2", + "account": { "account_id": "acc_luma_shared" }, + "starting_position": "latest", + "max_results": 100 +} +``` + +## Response + +**Response schema:** [`account/list-account-changes-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/list-account-changes-response.json) + +| Field | Description | +| --- | --- | +| `changes` | Oldest-first material change records. | +| `cursor` | Checkpoint after the scanned high-water. Always present, even on an empty tail page. | +| `has_more` | Whether more retained matching records were available when the page was generated. | +| `available_since` | Current retention boundary for this caller and account. | +| `generated_at` | Seller time for the page and coverage watermarks. | +| `source_coverage` | Optional per-account connected-source status and mechanically evaluable freshness. A connected source reporting `current` supplies `last_successful_sync_at` and `stale_after_seconds`. | + +Each change record names the changed resource, action, server-derived origin, +changed paths, optional revision, and an allowlisted read-only +`repair.task` hint. It deliberately does not include full before/after values. + +`resource_revision` carries the resource family's seller revision when one is +defined: `revision` for a media buy or account, and `seller_revision` for a +creative. It is the value immediately after that change, in the same JSON type +the read uses, so a buyer can tell whether a read it already holds reflects +the change; a read returning a different value reflects later changes. The seller advances that revision on every committed change from +any origin, including the changes this feed records for seller operators, +seller automation and connected platforms. Families without a seller revision +(package, creative assignment, audience, catalog, event source, property list, +product and signal as of 3.3) omit it, and the buyer repairs by rereading. A record coalesced under its media buy may carry the media buy's `revision`, because the repair read is [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys). Records for state the revision does not cover, such as `health` and `impairments`, omit it. + +### `changed_paths` + +`changed_paths` lists RFC 6901 JSON Pointers to the material fields that +changed, so a buyer can decide whether a change matters before it rereads. + +- When `origin.kind` is `adcp` or `seller_operator`, the seller processed the + mutation and knows what it changed, so the record MUST carry a non-empty + `changed_paths`. When the whole resource was created, deleted, or purged, + use the root pointer `""`. +- When `origin.kind` is `seller_system` or `connected_platform`, `changed_paths` + stays best-effort. A seller that observed a change through a source that does + not report what moved omits it, and the buyer rereads the resource. +- Paths are RFC 6901 pointers into the document the `repair.task` read returns + for the resource. Array indices are positions at the time of the change and + can shift when elements are removed or reordered, so treat a path as a hint + about what to look at, not as a stable address. +- The list is a cover, not an exhaustive diff. When a change touches more than + the 64 paths a record can hold, name the nearest common ancestor pointer, or + `""` if there is none. Do not truncate. +- Origin kinds follow where the seller learned of the change: `adcp` is an AdCP + task call, `seller_operator` is seller staff acting through the seller's own + surface, `seller_system` is seller automation, and `connected_platform` is a + change the seller observed in an external system, whoever made it there. A + seller does not relabel a change to avoid the `changed_paths` requirement. +- A `changed_paths` of `""` means reread the whole resource and is the only + entry in that list. +- Paths never carry values, and the buyer still repairs from the authoritative + read rather than from the paths. +- A 3.2 seller can omit `changed_paths` on these origins. Buyers tolerate that, + treat it as unknown, and reread the resource. + +### Recognizing your own changes + +An `adcp` change record can echo the `idempotency_key` of the request that +produced it, so a buyer on a shared account can tell its own mutations from +another principal's. + +- The seller includes `idempotency_key` only on records whose `origin.kind` is + `adcp`, and only on records served to the principal that sent that request. + Every other reader of the account sees the record with the field omitted. +- Apart from that field, records are the same across readers. The seller does + not vary whether a record appears, or its `change_id`, `resource`, `action`, + `origin.kind`, or `changed_paths`, by reader. Redaction the schema already + allows, such as `actor` and `origin.connection_id`, stays reader-specific. +- The key is a correlation token for the sender's own use. Compare it with the + keys the buyer sent; a match means the change came from that request. A buyer + that sees no key on an `adcp` record does not conclude another principal made + the change, because a seller without the echo omits it for everyone. "The + principal that sent the request" is the principal whose authenticated scope + the key was evaluated under, the same identity the cursor binds to, not a + particular user or credential behind it. A seller that echoes the key keeps the + originating principal and key for the full retention window, which outlasts + the idempotency replay window. +- One request can produce several records, such as one per creative, so match + the keys you sent as a set. A key can recur outside the replay window, so read + a match against a record's `recorded_at`. A mutation that completes + asynchronously keeps the original request's key and principal. +- A seller echoes the key only when the record covers exactly one originating + request. A record that coalesces changes from several requests, for example + package changes folded under a media buy, omits it. A record never coalesces + changes across origin kinds or principals. +- A match is a dedupe hint, not a substitute for the authoritative read. The + seller may have normalized the change, or a later change may have landed since, + so reread unless the mutation response already carried the final state. Persist + keys before sending requests, and keep them for as long as you want to + recognize the record, up to the 90-day retention window. +- `account.change_recorded` notifications never carry the originating key, + because the notification is an invalidation that every subscriber on the + account receives, and the key belongs to one principal's view of the feed. The + `idempotency_key` on that payload is the delivery key for one + fire, never a request key. Read the record from the feed. + +```json +{ + "change_id": "chg_01K38H2R5D7B0V9N3C4S8E6J1P", + "recorded_at": "2026-08-24T12:04:19Z", + "resource": { + "type": "media_buy", + "account_id": "acc_luma_shared", + "resource_id": "mb_5512" + }, + "action": "updated", + "origin": { "kind": "adcp" }, + "idempotency_key": "9f2c1d7e-3b5a-4c68-8e10-a7d4b2f6c901", + "changed_paths": ["/packages/0/budget"], + "repair": { "task": "get_media_buys" } +} +``` + +The same record served to any other principal on `acc_luma_shared` carries the +same `change_id`, `resource`, `action`, `origin.kind`, and `changed_paths`, minus +`idempotency_key`. + +```json +{ + "changes": [ + { + "change_id": "chg_01K38G7X8ZGX9T4F1Q5W6Y2M3N", + "recorded_at": "2026-08-24T11:58:04Z", + "resource": { + "type": "creative", + "account_id": "acc_luma_shared", + "resource_id": "cr_8421" + }, + "action": "updated", + "origin": { + "kind": "connected_platform", + "connection_id": "conn_social_primary" + }, + "resource_revision": 12, + "changed_paths": ["/name", "/assets/0"], + "repair": { "task": "list_creatives" } + } + ], + "cursor": "opaque-checkpoint", + "has_more": false, + "available_since": "2026-05-26T00:00:00Z", + "generated_at": "2026-08-24T12:00:00Z" +} +``` + +## Cursor contract + +Cursor order, not timestamps, defines the total order. Appends never reorder +prior pages. A filtered empty page still advances across scanned nonmatching +records, so persist every returned cursor. + +A cursor is scoped to the authenticated principal, resolved account, and +normalized filters. Do not move it between credentials or filter sets. Sellers +MUST reject a principal, account, or filter mismatch with [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) at +`cursor`; applying new filters at an old position could permanently skip +history. + +The cursor also binds an authorization-scope epoch. If visible resources for +the principal expand or contract, the seller returns [`CURSOR_EXPIRED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-cursor-expired) with +`details.reason: "authorization_scope_changed"`. Rebootstrap snapshots so +changes skipped under the old visibility cannot become an invisible gap. + +Sellers retain changes for at least 90 days. An expired cursor returns +[`CURSOR_EXPIRED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-cursor-expired); it never silently restarts. Recovery is to obtain a new +`latest` checkpoint, rebuild all authoritative snapshots, then drain changes +after the checkpoint. + +## Race-free bootstrap + +1. Register `account.change_recorded` through [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts#account-change-feed-notifications). +2. Obtain C0 with `starting_position: "latest"`. +3. Enumerate [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts), [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys), [`list_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives), financials, + delivery, and other advertised reads for the account. Include all lifecycle + statuses and exhaust every page. +4. Drain changes after C0. Locally allowlist `repair.task`, construct and + validate the read request from the authenticated account and `resource` + identity, then invoke it. Never dispatch feed-supplied task arguments. +5. Persist the returned cursor and repeat after each signed notification. +6. Poll periodically so a missed webhook does not create a gap. + +The webhook's optional `through_cursor` is only a target watermark. Do not +install it without reading all intervening pages. + +## Invoices + +A seller that lists `invoice` in `account.change_feed.resource_types` tells the buyer about invoices through this feed. There is no separate invoice notification type; the buyer receives the signed `account.change_recorded` notification and drains the feed like any other resource. + +Invoice records name the invoice and point at [`get_account_financials`](/dist/docs/3.3.0-beta.3/accounts/tasks/get_account_financials) for repair: + +```json +{ + "change_id": "chg_01K4B2T7QH8V3N5R6D9W0X1Y2Z", + "recorded_at": "2026-09-01T09:15:00Z", + "resource": { + "type": "invoice", + "account_id": "acc_luma_shared", + "resource_id": "inv_2026_08_0042" + }, + "action": "created", + "origin": { "kind": "seller_system" }, + "repair": { "task": "get_account_financials" } +} +``` + +| Action | When | +| --- | --- | +| `created` | The invoice first becomes visible to the buyer, normally at issue. A `draft` invoice is not recorded until it is visible to the buyer. | +| `status_changed` | A later status transition. `reason` is the new invoice status: `issued`, `paid`, `past_due`, or `void`. | +| `updated` | An amount or period correction. | + +`recorded_at` and `occurred_at` carry the time of the change; the invoice has no separate issue-time field. Changes to spend, credit, or payment status keep `type: "account_financials"` with `resource_id` equal to the account id. + +**Repair completeness.** An implementation MUST NOT advertise a resource type whose current material state cannot be recovered through its named read (see the [coverage matrix](https://github.com/adcontextprotocol/adcp/blob/main/specs/account-change-feed.md#authoritative-coverage-matrix)). A seller that lists `invoice` in `resource_types` therefore MUST return, in `get_account_financials.invoices[]` (overriding any seller limit on the list), each invoice that has a change record inside the retention window (`account.change_feed.retention_days`) whenever the request's `period` overlaps that invoice's `period`. A request without `period` MUST return every such invoice whatever its `period`, so a buyer repairing from a change record omits `period`. + +**Intermediaries.** An intermediary that consolidates billing is the seller of record for the invoices it issues. It records them on its own feed to its buyer and does not relay upstream sellers' invoice records. + +Sellers that do not offer the experimental feed have no invoice notification; buyers keep polling `get_account_financials`. + +## Change granularity + +The feed contains at least one record per independently repairable identity. +Several package or assignment changes may coalesce under their media buy when +[`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys) repairs the complete changed closure; changes to independent +creatives require separate records. Records from one operation may share +`batch_id`, but retain independent `change_id` values and notifications. + +For seller-mediated mutations, snapshot and records commit atomically. For an +external platform, records commit with the seller's ingestion of the observed +change. A pass-through authoritative read may lead the feed only within the +source's declared `stale_after_seconds` freshness bound. + +## Shared-account example + +Suppose a connected platform changes `cr_8421` while the buyer is idle. The +seller records the creative revision, makes it visible on [`list_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives), +appends the account change, and then fires `account.change_recorded`. The buyer +drains from its cursor and rereads `list_creatives`; it does not treat the +webhook payload as the creative document. + +This is the same flow for a seller operator changing a campaign budget or +another authorized buyer pausing a package. Account visibility is based on +current authorization, not on which principal created the resource. + +## Relationship to `webhook_activity` + +`webhook_activity[]` answers whether a webhook delivery was attempted. This +feed answers which material business-state changes were recorded. A change can +exist even when a webhook is missed; one change can also have several delivery +attempts. Never use the transport log as account history. diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts.mdx new file mode 100644 index 00000000000..c1d34d7dfb8 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts.mdx @@ -0,0 +1,297 @@ +--- +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 recover the exact account reference expected on protocol operations. + +Account visibility is source-neutral: the seller MUST return every account the +authenticated caller can operate, including pre-existing accounts created or +managed through seller and connected-platform surfaces. `list_accounts` is not +limited to relationships first provisioned through [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). + +`list_accounts` returns accounts, and every account has an `account_id`. A +seller that keeps pending provisioning as [account requests](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) +(experimental in 3.3) lists the account only once its request resolves; until +then, read the request in `account_requests[]` by setting `include_requests: true` +(or `request_ids`). Requests page separately from accounts: `account_requests_pagination` +in the response, `requests_pagination` in the request. The top-level `pagination` pages +accounts only. + +For upstream-managed account namespaces, `list_accounts` is not optional discovery polish; it is the namespace discovery contract. The upstream platform owns the accessible account set, so buyers MUST resolve an explicit `account_id` before the first account-scoped request. If the authenticated credential can access more than one account, the seller MUST expose `list_accounts`; if it can access exactly one account, the seller SHOULD expose `list_accounts` returning that singleton so SDKs can auto-select it and still send `{ "account_id": "..." }` on required-account calls. [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) provisioning does not create account-id accounts in 3.0.x unless a future explicit capability declares that mode; if `sync_accounts` is exposed on these sellers today, use it only for settings updates against an account already identified by `account_id`. + +`list_accounts` works across all vendor protocols — media buy agents, signals agents, governance agents, and creative agents all return accounts through this same task. + +**Response Time**: ~1s. + +**Request Schema**: [`static/schemas/source/account/list-accounts-request.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/source/account/list-accounts-request.json) +**Response Schema**: [`/schemas/3.3.0-beta.3/account/list-accounts-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/list-accounts-response.json) + +## Quick Start + +List all accounts this agent can operate: + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { ListAccountsResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.listAccounts({}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = ListAccountsResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +for (const account of validated.accounts) { + console.log(`${account.account_id}: ${account.name} (${account.status})`); +} +``` + +```python Python +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.list_accounts() + + if hasattr(result, 'errors') and result.errors: + raise Exception(f"Operation failed: {result.errors}") + + for account in result.accounts: + print(f"{account.account_id}: {account.name} ({account.status})") + +asyncio.run(main()) +``` + + + +## Request Parameters + +All parameters are optional. An empty request returns all accounts visible to +the authenticated caller. Use `account` to re-read one known account by +seller-assigned `account_id`, or to find buyer-declared accounts by natural key. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `account` | object | No | Account filter. Pass `{ "account_id": "..." }` for one known account. For buyer-declared accounts, pass the natural key, `brand` and `operator`, narrowed by any account attribute such as `brand.countries` or `timezone`. The seller returns every matching account visible to the authenticated caller. | +| `status` | string | No | Filter by account status: `active`, `pending_approval`, `rejected`, `payment_required`, `suspended`, or `closed`. | +| `sandbox` | boolean | No | When true, return only sandbox accounts. When false, return only production accounts. Omit to return both. Primarily used with account-id namespaces where sandbox accounts are pre-existing test accounts on the platform. | +| `include_webhook_activity` | boolean | No | When true, request recent webhook delivery attempts for each returned account in `webhook_activity[]`. | +| `webhook_activity_limit` | integer | No | Maximum number of `webhook_activity[]` records to return per account when `include_webhook_activity` is true. Default 50, max 200. | +| `pagination` | object | No | Pagination cursor for large account sets. Pages `accounts` only. | +| `include_requests` | boolean | No | Experimental (`account.requests`). Also return the caller's [account requests](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) in `account_requests[]`: every open request plus requests that changed state in the last 30 days. | +| `request_ids` | array | No | Experimental. Return only these requests; implies `include_requests`. Unknown IDs and other callers' IDs are omitted silently. | +| `requests_pagination` | object | No | Experimental. Pages `account_requests[]` independently of accounts; implies `include_requests`. | + +## 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 of `accounts`, if more exist | +| `account_requests` | Experimental. The caller's account requests, when `include_requests`, `request_ids`, or `requests_pagination` was sent. Requests are not accounts: an open request has no `account_id` until it resolves. | +| `account_requests_pagination` | Experimental. Paging for `account_requests[]`. | + +**Each account includes:** + +| Field | Description | +|-------|-------------| +| `account_id` | Seller/storefront identifier and the account's identity. Use it on every account-scoped task, in both account models. It is distinct from `operator_unit.id`, which belongs to the buyer-side operator. May be absent when `status: "rejected"`. | +| `name` | Vendor agent's display name for the account | +| `brand` | Brand reference: `domain`, optional `brand_id`, and the optional `countries[]` account attribute. Countries are not delivery targeting. | +| `operator` | Operator domain. Always present — when the brand operates directly, `operator` equals the brand's domain. | +| `operator_unit` | Optional operator-owned unit, an account attribute. `name` is display metadata. | +| `revision` | Current account optimistic-concurrency revision. Pass it in the next [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) change or [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) settings update. | +| `identity_change` | **Deprecated in 3.3.** Pending or rejected desired operator identity. While present, `operator` and `operator_unit` remain the current canonical identity. Poll until the change applies or is rejected. | +| `currency` | Optional account currency. When present, media buys on the account must use it. | +| `timezone` | Operational timezone for the account. It is the default for account-scoped calendar behavior unless a feature explicitly declares another timezone basis. | +| `status` | Current account state: `active`, `pending_approval`, `rejected`, `payment_required`, `suspended`, or `closed`. With a seller that keeps pending accounts as [requests](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account), `pending_approval` and `rejected` appear on the request, not on an account. | +| `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.3.0-beta.3/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.3.0-beta.3/accounts/tasks/sync_governance). | +| `reporting_delivery_configs` | Experimental caller-visible resolved durable reporting configuration states, including setup/readiness, destination binding, immutable generation, and deactivation cutoff. Another caller's configurations are never returned. | +| `setup` | Present when `status: "pending_approval"`. Contains `url` for completing setup and `message` explaining what's needed. | +| `authorization` | Optional. The calling agent's scope grant for this account — `allowed_tasks`, `field_scopes`, `scope_name`, `read_only`. Applies to every vendor agent type (media-buy, signals, governance, creative, brand) — the Accounts Protocol surface is shared. Vendor agents that support scope introspection SHOULD populate this; media-buy sales agents claiming the `attestation_verifier` standard scope MUST populate it. Absence means the vendor agent does not advertise introspectable scope for this account; callers MUST NOT infer access from absence and fall back to error-driven discovery via the RBAC error codes. See [Caller authorization](/dist/docs/3.3.0-beta.3/accounts/overview#caller-authorization) for the full shape and semantics. Experimental `authorized_at` (`media_buy.reporting_subscriptions`) says when the current grant to this caller began; reporting subscriptions cover the account from the first period boundary at or after it. Experimental `regranted_at` is the latest re-grant within that chain, absent when there has been none. | +| `notification_configs` | Account-level webhook subscribers registered via [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts#account-level-webhook-subscriptions). Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`. Present when the account has any persisted subscribers. `subscriber_id` is the account-scoped logical key; re-registering the same subscriber replaces that subscriber's config. `authentication.credentials` is omitted on every entry (write-only). Use this surface to verify what's active after a sync, audit fan-out across multiple subscribers, and detect drift between buyer-side expectations and seller-side persisted state. `account.status_changed` subscribers repair by rereading this account; `account.change_recorded` subscribers drain [`list_account_changes`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes) and invoke each record's repair task. | +| `webhook_activity` | Optional recent webhook delivery attempts for this account, returned when `include_webhook_activity: true` and the seller exposes the debug log. Omitted means unsupported or not requested; `[]` means supported but no retained fires; non-empty records are most recent first. | + +For buyer-declared accounts, `list_accounts` MUST return each account's `account_id` with its current `brand`, `operator`, and attributes. A stateless buyer recovers its account IDs this way after a cold start and sends `{ "account_id": "..." }` on later tasks. A natural-key `account` filter returns every matching account, so several accounts for one brand and operator are a normal result here, not an ambiguity. + +### Mapping buyers to listed accounts + +When the seller owns the account namespace, the caller usually maps its own buyers onto accounts it already has, and asks for a new one only when none fits. A storefront holding credentials for its ad servers' sales agents works this way, as does a buyer on a social platform. The caller needs to know what each account is for: + +- Sellers SHOULD return [`account_scope`](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-scope) on every account, and `brand` and/or `operator` wherever the scope names them. +- When several accounts could serve a buyer, a person chooses. The choice decides who is invoiced, so there is no default order between scopes, and a caller does not pick one automatically. +- When nothing fits, ask for an account with [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) (experimental in 3.3) or `sync_accounts`. + +### Single-publisher cardinality + +A seller serving exactly one publisher entity MAY return that entity as the sole account on `list_accounts` responses, regardless of calling principal. The *"Direct advertiser with single account"* example in [`list-accounts-response.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/source/account/list-accounts-response.json) is canonical for this case — a single-element `accounts[]` with no `pagination` envelope at all. + +Pagination conformance requiring `pagination.has_more: true` does not apply when: + +- `pagination` is absent entirely (canonical single-account shape), or +- `pagination.total_count` is present and ≤ 1 + +Runners SHOULD grade pagination-walk phases as `not_applicable` in either case. This pattern is conformant; the spec carries no `minItems` constraint on `accounts[]` and the single-account example is normative. + +## Common Scenarios + +### Poll until account becomes active + +After [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) returns `pending_approval`, poll until the account is ready. With [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account), poll the request instead, with `request_ids`: + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { ListAccountsResponseSchema } from "@adcp/sdk"; + +async function waitForAccount(targetAccountId, maxAttempts = 20) { + for (let i = 0; i < maxAttempts; i++) { + const result = await testAgent.listAccounts({ status: "active" }); + + if (!result.success) { + throw new Error(`Request failed: ${result.error}`); + } + + const validated = ListAccountsResponseSchema.parse(result.data); + + if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); + } + + if ("accounts" in validated) { + const account = validated.accounts.find(a => a.account_id === targetAccountId); + if (account) { + console.log(`Account active: ${account.account_id}`); + return account; + } + } + + // Wait 30 seconds before polling again + await new Promise(resolve => setTimeout(resolve, 30_000)); + } + + throw new Error(`Account ${targetAccountId} did not become active`); +} +``` + +```python Python +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") +``` + + + +### Reconcile account status webhooks + +After receiving `account.status_changed`, re-read the account snapshot. The webhook tells you which account changed; `list_accounts` is the source of truth for the current status, setup hints, billing terms, and authorization. + +The [seller-mediated account provisioning walkthrough](/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough#observe-and-reconcile-resolution) shows this invalidation-and-repair pattern in context. + +```javascript +async function handleAccountStatusWebhook(req, res) { + if (!verifyWebhookSignature(req)) return res.status(401).end(); + + const fire = req.body; // account-status-changed-webhook + if (await alreadyProcessed(fire.idempotency_key)) return res.status(200).end(); + + const result = await testAgent.listAccounts({ + account: { account_id: fire.account_id }, + include_webhook_activity: true, + webhook_activity_limit: 20 + }); + + const account = result.accounts[0]; + await reconcileAccount(account); + await markProcessed(fire.idempotency_key); + return res.status(200).end(); +} +``` + +### Filter active accounts only + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { ListAccountsResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.listAccounts({ status: "active" }); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = ListAccountsResponseSchema.parse(result.data); + +if ("accounts" in validated) { + for (const account of validated.accounts) { + console.log(`${account.account_id}: ${account.name} — billing: ${account.billing}`); + } +} +``` + +```python Python +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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found) | No accounts found for this agent | Open an account first with [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) (when the seller supports `account.requests`) or [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) | + +## Next Steps + +- [request_account](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) — Open or change an account (experimental, `account.requests`) +- [sync_accounts](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) — Sync advertiser accounts with a seller +- [sync_governance](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) — Sync governance agents to accounts +- [Accounts and agents](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents) — Billing models, trust models, and authorized operators +- [Brand protocol](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json) — How vendor agents resolve brand identity from the brand's `domain` diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/report_usage.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/report_usage.mdx new file mode 100644 index 00000000000..825c8e54087 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/report_usage.mdx @@ -0,0 +1,197 @@ +--- +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. It is also used against the sales agent itself when a media buy names the buyer or a measurement vendor as the billing authority. + +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. + +{/* Add the /schemas/v3/ request link once the revenue-share fields ship in a stable release. */} +**Response Schema**: [`/schemas/3.3.0-beta.3/account/report-usage-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals), [`list_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives), [`list_content_standards`](/dist/docs/3.3.0-beta.3/governance/content-standards/tasks/list_content_standards), [`list_property_lists`](/dist/docs/3.3.0-beta.3/governance/property/tasks/property_lists#list_property_lists)) or execution response ([`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/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 | +| `media_buy_id` | string | Revenue share: Yes | Media buy whose selected package pricing is being reconciled. | +| `conversions` | number | No | Number of attributed conversion events. Optional analytics context for revenue share. | +| `conversion_value` | number | No | Total attributed conversion value. Optional analytics context; not the revenue-share billing basis. | +| `commissionable_value` | number | Revenue share: Yes | Settled attributed value eligible for commission. The seller verifies `vendor_cost = round_currency(commissionable_value × commission_rate)`. | +| `signal_agent_segment_id` | string | Signals: Yes | Signal identifier from `get_signals` | +| `creative_id` | string | Creative: Yes | Creative identifier from `build_creative` or `list_creatives`. Links usage to a specific creative for billing verification. A `build_creative` variant leaf earns a `creative_id` only when trafficked/added to the library — discarded best-of-N or fan-out variants are never reported here; their charge is the inline per-leaf `vendor_cost` on the `build_creative` response (the authoritative record for untrafficked leaves). | +| `build_variant_id` | string | No | When the reported `creative_id` was promoted from a specific `build_creative` variant leaf but differs from that source id, carry the source `build_variant_id` for reconciliation. On the canonical path where `creative_id` is the kept `build_variant_id`, omit this field. | +| `property_list_id` | string | Property lists: Yes | Property list identifier from `list_property_lists`. Links usage to a specific property list for billing verification. | + +For revenue-share records, `pricing_option_id` identifies the agreed rate and commission basis. `currency` must match the selected option, and `vendor_cost` must not exceed the package's commission budget. + +## 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. + +An idempotent replay is transparent: the server returns the original response unchanged and does not emit a `DUPLICATE_REQUEST` error or advisory. + +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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found) | Account reference in a usage record not found or not accessible | Verify via [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts); re-run [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) if needed | +| [`INVALID_USAGE_DATA`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-usage-data) | A usage record has missing or invalid fields | Check required fields for your vendor type | +| [`INVALID_PRICING_OPTION`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-pricing-option) | `pricing_option_id` not found on this account | Verify `pricing_option_id` from the vendor's discovery response | + +## Next Steps + +- [sync_accounts](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) — Sync advertiser accounts with a seller before reporting +- [Accounts Protocol](/dist/docs/3.3.0-beta.3/accounts/overview) — How account establishment and settlement fit together +- [Accounts and agents](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents) — Billing hierarchy and operator model diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/request_account.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/request_account.mdx new file mode 100644 index 00000000000..953816e0889 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/request_account.mdx @@ -0,0 +1,237 @@ +--- +title: request_account +description: "request_account is the single account write: open an account (the seller matches or creates it) or change one by account_id, possibly after human review on either side." +"og:title": "AdCP — request_account" +testable: false +--- + +`request_account` is the one way to write an account. Send a brand and operator to open one: the seller looks for an existing account first, creates one if it should, or tells the buyer what a human has to do before it can. Send an `account_id` and attributes to change one. Every write is a request with the same lifecycle, read back through [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts). + + +**Experimental.** Account requests ([`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account), `include_requests` / `request_ids` on `list_accounts`, the `account_request.changed` webhook, the `account.requests` capability block, and the `request_id`, `resolution`, `setup.waiting_on`, and `setup.kind` fields on [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) results) are an experimental AdCP 3.3 surface ([#8280](https://github.com/adcontextprotocol/adcp/issues/8280), [#8543](https://github.com/adcontextprotocol/adcp/issues/8543)). They may change between 3.x releases with at least 6 weeks' notice. Sellers implementing any part of it MUST declare `account.requests` in `experimental_features`. See [experimental status](/dist/docs/3.3.0-beta.3/reference/experimental-status). + + +**Request schema:** [`account/request-account-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/request-account-request.json) +**Response schema:** [`account/request-account-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/request-account-response.json) + +## Why a request, not an account + +Getting an account usually involves a person. When a storefront receives "WPP Media FR for Renault," someone in sales ops checks the CRM, where the record is named something like "Renault SAS – France," not keyed on a domain. The review can end four ways: the seller already has a relationship and links it, creates a new account, asks a human on the buyer side to sign up first, or declines. + +[`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) models this as a placeholder account in `pending_approval`. A placeholder can't become an existing account without changing its `account_id`, and a rejected placeholder keeps holding the brand and operator. A request is its own object instead: no account exists until the request resolves, so a match never changes an ID and a rejection never blocks a retry. + +## When to call + +Call it only when [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) advertises `account.requests.supported: true` and lists `account.requests` in `experimental_features`. Otherwise, use [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts), which stays stable and available in 3.3. `account.requests.changeable_attributes` lists the attributes a seller accepts in change requests; absent or empty means it supports opening accounts only. + +- **Agent-declared sellers** (storefronts, inventory sources, house seats): `request_account` is the way to get an account. +- **Sellers that own their account namespace** (platforms where the credential's access decides which accounts exist): `request_account` is optional. When offered, it matches among accounts the credential already reaches, or returns a buyer action such as creating an ad account or asking the brand to grant access. Sellers that don't offer it rely on [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts), and the brand-to-account mapping stays on the buyer side. + +## Lifecycle + +| `state` | Carries | What the buyer does | +| --- | --- | --- | +| `in_review` | `pending_action` | `waiting_on: seller`: wait for `account_request.changed` or poll [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with `request_ids`. `waiting_on: buyer`: show `pending_action.url` to a human, then keep waiting. | +| `resolved` | `account_id`, `resolution` (`matched`, `created`, or `updated`), and `account`: always for `matched` and `created`, with the account's money terms, and for a change the updated account | Store `account_id` and send it on every account-scoped task. Check the money terms in `account`: they can differ from the ones requested. | +| `ambiguous` | `candidates[]` | A human picks one; call `request_account` with that `account_id` and the open `request_id`, and the request resolves as `matched`. | +| `rejected` | `errors[]` | Tell a human. A new request for the same key opens a new request. | + +`in_review` and `ambiguous` are open; `resolved` and `rejected` are terminal for that request. A seller that approves automatically resolves in the same response. A seller that reviews starts `in_review` with `waiting_on: seller`. + +`pending_action.kind` says what is needed: `review`, `sign_up`, `accept_terms`, `credit_application`, `add_payment`, `grant`, or `connect_credentials`. The list is extensible; for an unknown kind, fall back to `message` and `url`. + +## Request + +| Field | Required | Description | +| --- | --- | --- | +| `idempotency_key` | Yes | Makes retries of this call safe. A replayed key returns the original request; see [deduplication](#deduplication). | +| `brand` | To open | BrandKey: `domain`, optional `brand_id`, optional `countries[]`. With `account_id`, send it only to change countries; `domain` and `brand_id` must equal the account's. | +| `operator` | To open | Domain of the entity operating for the brand; the brand's own domain when it buys direct. | +| `operator_unit` | No | Operator-owned unit or seat. `id` is an account attribute; `name` is display metadata. | +| `currency` | No | ISO 4217 currency for a currency-bound advertiser object. An account attribute set when the account opens. | +| `timezone` | Conditional | Buyer-selected timezone, as in [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). An account attribute set when the account opens. | +| `sandbox` | No | Request a sandbox account. An account attribute. | +| `billing` | No | Requested invoiced party: `operator`, `agent`, or `advertiser`. Accepted exactly or rejected. | +| `billing_entity` | No | Requested business entity for the invoiced party. Bank details are write-only. | +| `payment_terms` | No | Requested terms. Accepted or rejected, never silently remapped. | +| `request_id` | No | Continue one of your own open requests: with `account_id` and no change fields, pick a candidate from an `ambiguous` request or name the account a human created after a `sign_up` action (the request resolves as `matched`). Change requests are never edited in place; send a new one, which supersedes the open one. | +| `account_id` | To change or select | Change this account: every other field present is a requested change. With no other fields, select it: a candidate from an `ambiguous` result, or an account the credential doesn't reach yet. | +| `revision` | For money changes | With `account_id`: the account revision a change is based on. **Required** when the change touches `billing`, `billing_entity`, or `payment_terms`, since a lost update there costs money; optional otherwise. A stale revision fails with [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict) and changes nothing. | + +Send either `account_id` (to change or select), or `brand` and `operator` (to open). A request with `account_id` and `operator`, or with neither, fails with [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request). + +```json +{ + "adcp_version": "3.3", + "idempotency_key": "3f1d2a8c-6b0e-4c7a-9d51-8e2f4a6b9c10", + "brand": { "domain": "renault.fr", "countries": ["FR"] }, + "operator": "wppmedia.fr", + "currency": "EUR", + "billing": "operator", + "billing_entity": { "legal_name": "WPP Media France SAS" } +} +``` + +## Changing an account + +Send the `account_id` and only the attributes to change. The seller lists the attributes it changes in `account.requests.changeable_attributes`, from the brand's `countries`, `operator_unit`, `billing`, `billing_entity`, `payment_terms`, `currency`, and `timezone`. `currency` and `timezone` are set when the account opens and change only on a seller that lists them; anywhere else the change is refused with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature). A timezone change is a reporting cutover: account-timezone periods switch at the first old-timezone boundary at or after it, as [reporting subscriptions](/dist/docs/3.3.0-beta.3/protocol/sync_principal#reporting-subscriptions) describe. `sandbox` never changes. Set `allow_separate_account: true` if you would accept the change on a separate new account when the seller can't make it on this one; it defaults to `false`. + +```json +{ + "adcp_version": "3.3", + "idempotency_key": "9d4e1b7a-2c6f-4a83-b5e0-7f1a3c8d2b64", + "account_id": "acc_nova_pinnacle", + "revision": 7, + "brand": { "domain": "nova-athletics.example", "countries": ["BE", "DE", "NL"] } +} +``` + +The seller validates a changed attribute as it does when the account opens, including authorization such as the brand-operator check. Then one of: + +- **It applies the change:** `state: resolved`, `resolution: updated`, and `account` carrying the updated attributes and new `revision`. When the change touches `billing`, `billing_entity`, or `payment_terms`, the request also carries `effective_at`, when the new money terms take effect: delivery before it is invoiced under the old terms. The seller may pick a later instant than resolution, such as its next invoice-period boundary. Other attributes change when the request resolves. +- **It reviews the change first:** `state: in_review` with a pending action. The account keeps its current attributes until the request resolves. +- **It refuses the change:** `state: rejected` with the reason. The account is unchanged. +- **It opens a separate account instead, if the buyer allowed it:** `state: resolved`, `resolution: created`, and a new `account_id`, with `account` carrying the new account. The seller says "yes, but as a separate account", for example when a new market needs its own contract or invoicing entity. The seller may do this only when the request set `allow_separate_account: true`. The new account has the named account's attributes with the requested change applied, and `account` carries its money terms. Terms the seller needs to set differently go through a pending action before the request resolves. The request's `not_carried_over` lists the named account's account-level configurations the new account doesn't have, from `notification_configs`, `reporting_delivery_configs`, `reporting_bucket`, `governance_agents`, `creatives`, `audiences`, `catalogs`, and `event_sources`; `[]` means all of them carried over. Principal-level configuration is unaffected: an `all_authorized_accounts` reporting subscription covers the new account once it is authorized, while an `account_ids` subscription covers it only after you add it. +- **It would accept the change only as a separate account, and the buyer didn't allow that:** `state: rejected` with [`ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-change-requires-new-account). The account is unchanged. The buyer can tell this from a plain refusal: it may open a new account itself, resend the change with `allow_separate_account: true`, or keep the account as it is. +- **It points to an account the caller already holds:** `state: resolved`, `resolution: matched`, and that account's `account_id`, with `account` carrying its money terms. The seller uses this when the change would make the named account duplicate an account the caller can already reach; see [Accounts that end up alike](/dist/docs/3.3.0-beta.3/accounts/overview#account-identity). As with `candidates`, the seller MUST NOT point to an account outside the caller's grants. + +When the request resolves to another account (`created` or `matched`), the named account is untouched: it keeps its attributes, media buys, and reporting history, and stays usable. The seller MUST NOT move media buys, creatives, or reporting between the two accounts. Which account carries new buys is the buyer's choice. A buyer can tell these outcomes apart because the resolved `account_id` differs from `requested.account_id`. + +When a change is applied, the account keeps its `account_id`, media buys, and reporting history. Changes never alter the account's identity, so there is nothing to retire. What a seller may use `countries` for, and what a change to them does to accepted media buys, is set out in [Account identity](/dist/docs/3.3.0-beta.3/accounts/overview#account-identity). + +**Who may change an account.** The seller first resolves `account_id` against the caller's own access and answers an unreachable account exactly as an unknown one, before validating anything else in the body, so a probe learns nothing. A change then requires `request_account` in the caller's [grant](/dist/docs/3.3.0-beta.3/accounts/overview#caller-authorization) `allowed_tasks` for that account, with `read_only: false`. When the grant lists `request_account` under `field_scopes`, every changed field, including `billing`, `billing_entity`, and `payment_terms`, must be in that allowlist, as `field_scopes` already works. Otherwise the request fails with [`SCOPE_INSUFFICIENT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-scope-insufficient), [`READ_ONLY_SCOPE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-read-only-scope), or [`FIELD_NOT_PERMITTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-field-not-permitted). + +**Concurrent changes.** A change that waits `in_review` re-checks `revision` when it resolves. If the account changed in the meantime, the request is rejected with [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict) instead of being applied on top. A new change request for the same account from the same caller **supersedes** that caller's open change request: the new request gets its own `request_id` and must carry every change still wanted, and the open one moves to `rejected` with `CONFLICT` and `details.superseded_by`. Change requests are never edited in place, and another caller's requests are never affected. Subscription writes (webhook and reporting configuration) don't advance the account's `revision`, so they never fail a pending change. + +**One account per call.** `request_account` has no batch form. A buyer opening many accounts makes one call per account, which keeps each request's lifecycle separate. + +## Response + +A completed response always carries the request, whatever its state. `in_review` and `rejected` are normal outcomes, not task failures; `status: "failed"` is reserved for calls the seller couldn't process. + +```json +{ + "status": "completed", + "request": { + "request_id": "areq_01K4F8M2Q7", + "state": "in_review", + "requested": { + "brand": { "domain": "renault.fr", "countries": ["FR"] }, + "operator": "wppmedia.fr", + "currency": "EUR", + "billing": "operator" + }, + "pending_action": { + "waiting_on": "seller", + "kind": "review", + "message": "Checking for an existing Renault France relationship." + }, + "created_at": "2026-10-18T09:00:00Z", + "updated_at": "2026-10-18T09:00:00Z" + } +} +``` + +A day later, sales ops finds no relationship and hands the next step to the buyer: + +```json +{ + "request_id": "areq_01K4F8M2Q7", + "state": "in_review", + "pending_action": { + "waiting_on": "buyer", + "kind": "sign_up", + "url": "https://advertisers.streamhaus.example/register?ref=areq_01K4F8M2Q7", + "message": "No existing relationship found. Register WPP Media France as an advertiser.", + "expires_at": "2026-11-15T00:00:00Z" + } +} +``` + +Once the human registers, the request resolves. It carries `account` with the new account's money terms, because they can differ from the ones requested (`requested`, `created_at`, and `updated_at` are omitted here): + +```json +{ + "request_id": "areq_01K4F8M2Q7", + "state": "resolved", + "account_id": "acc_renault_fr_wpp", + "resolution": "created", + "account": { + "account_id": "acc_renault_fr_wpp", + "name": "Renault France via WPP Media", + "status": "active", + "billing": "operator", + "payment_terms": "net_45" + } +} +``` + +## Deduplication + +The protocol requires only that retries are safe. Replaying an `idempotency_key` returns the original request, and replaying it with a different body returns [`IDEMPOTENCY_CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-idempotency-conflict), as on every AdCP mutation. A new key is a new request. + +What a seller does with separate requests for the same advertiser is its choice. This guidance is non-normative: + +- **Per calling agent.** Requests are scoped to the authenticated caller. Two agents asking for the same advertiser get two requests, and both may resolve to the same account (`matched`), each with its own grant. +- **Match on brand and operator.** Treat a request for the same brand (`domain` and `brand_id`) and `operator` as the same advertiser. `brand.countries`, `currency`, `timezone`, `sandbox`, and `operator_unit` are attributes of the account: use them as filters that narrow which account fits, not as identity. +- **Terms are not identity.** `billing`, `billing_entity`, and `payment_terms` are requested terms. One advertiser maps to one account whoever is invoiced. + +A seller that does deduplicate typically answers a repeated *open* request as follows (change requests supersede instead; see [Changing an account](#changing-an-account)): + +| Existing request | Result | +| --- | --- | +| `in_review` or `ambiguous` | The same request. Changed terms replace the requested terms; the seller MAY restart review. | +| `resolved` | The same request and account, with the account's actual terms. Changing terms is a change request (`request_account` with the `account_id`), never a silent re-review. | +| `rejected` | A new request. The seller MAY reject it again at once, for example during a cool-down. | +| `resolved`, account since closed | A new request, which may create a new account. | + +## Seller rules + +- **No provisional account IDs.** Never issue an `account_id` that might later change; while a request might match an existing account, issue none until it resolves. Open requests are visible through [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with `include_requests`, in `account_requests[]`, never as accounts. +- **No existence oracle.** `matched` and `candidates[]` disclose only accounts the calling agent is, or is now, granted. When the only match is an account another operator or agent holds, every state, pending-action kind, and error the caller sees MUST be one the seller could also return when nothing matches: for example, `review` followed by `sign_up`, a new account, or a rejection with a generic reason. +- **Check reachability first.** Resolve `account_id` against the caller's access before any other validation, so [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request), [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict), or [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) never reveal that an unreachable account exists. +- **Never echo write-only fields.** `requested` never includes `billing_entity` bank details or other write-only fields. +- **Unreachable `account_id`s look like unknown ones.** For an `account_id` the caller can't reach, answer the same way whether or not the account exists: always `rejected` with [`ACCOUNT_NOT_FOUND`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found), or, for sellers that support access requests, always `in_review` with `waiting_on: buyer` and `kind: grant`. +- **Action URLs aren't bearer links.** Possession of `pending_action.url`, or of a `request_id` in it, MUST NOT by itself be enough to complete the action; the human authenticates with the seller first. +- **Grant the caller on resolution.** A resolved account appears in the caller's `list_accounts` with its [caller authorization](/dist/docs/3.3.0-beta.3/accounts/overview#caller-authorization). +- **Check authority while reviewing.** Verify the operator against the brand's `authorized_operators`, and, where supported, the operator's grant to the calling agent. A missing grant is `waiting_on: buyer` with `kind: grant`, not a bare error. +- **Expire buyer actions.** A request still waiting on the buyer at `pending_action.expires_at` moves to `rejected` with an error explaining the expiry. +- **Fire `account_request.changed` on every seller-driven transition,** after the new state is readable through `list_accounts`, when the capability declares `event_type`. Sellers that don't fire it leave buyers to poll. +- **Fold in the agent application.** If the calling agent's own relationship with the seller is not yet active, the seller MAY review it together with its first account request, so the buyer makes one call and the seller one review. + +## Relation to `sync_accounts` + +[`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) stays stable and available in 3.3; its formal deprecation follows `account.requests` leaving experimental status. A seller offering both keeps one internal request record behind them: `sync_accounts` provisioning rows are a projection of it. A `sync_accounts` settings update that lands while a `request_account` change is `in_review` advances the account's revision, so that change fails its revision re-check and is rejected with [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict). + +| Request state | `sync_accounts` row | +| --- | --- | +| `in_review` | `status: pending_approval`, `setup` with `waiting_on` and `kind`, `request_id`. `account_id` only when it can never change. | +| `resolved` / `created` | `action: created` (then `unchanged` on repeats), `account_id`, `resolution: created` | +| `resolved` / `matched` | `action: unchanged`, `account_id`, `resolution: matched` | +| `ambiguous` | `action: failed`, [`ACCOUNT_AMBIGUOUS`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-ambiguous) with `error.details.candidates[]` | +| `rejected` | `status: rejected` with the reason in `warnings`, as `sync_accounts` already reports a declined account. `action: failed` with `errors[]` stays reserved for entries the seller couldn't process. | + +**Buyers that predate account requests.** They ignore `request_id`, `resolution`, and the new `setup` fields, and learn the outcome the way they do today: by replaying `sync_accounts` with the same natural key, from the one-shot `push_notification_config`, or from `account.status_changed`. When a request resolves, the seller activates the row's account-level `notification_configs` first and then fires `account.status_changed` from `pending_approval` with the resolved `account_id`. The one visible difference is that a request-mode seller's pending rows don't appear in [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) until they resolve. + +**Placeholder accounts.** A seller never issues an `account_id` it might later change. While a request is pending, the buyer tracks `request_id`. A seller that creates the account at approval and keeps the placeholder's ID, so it never changes, MAY issue it early; any seller that might match the request to an existing account does not. + +## Buyer handling + +1. If the seller advertises `account.requests`, call `request_account` to open or change accounts. Otherwise use [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). +2. Store `request_id` against (seller, key). +3. While the request is open, if the seller declares `account.requests.event_type`, register for `account_request.changed` through [`sync_principal`](/dist/docs/3.3.0-beta.3/protocol/sync_principal) or [`sync_agent_notification_configs`](/dist/docs/3.3.0-beta.3/protocol/sync_agent_notification_configs), or poll [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with `request_ids` and backoff. +4. Until it resolves, call discovery tasks without `account`, which returns the seller's public view. +5. When a change request resolves with an `account_id` other than the one it named (`resolution: created` or `matched`), the named account is unchanged. Decide which account to use for new buys; nothing moves on its own. A change rejected with [`ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-change-requires-new-account) is acceptable on a new account: open one, or resend with `allow_separate_account: true`, if you want it. +6. After a cold start, [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with `include_requests: true` recovers accounts and open requests. Requests page separately from accounts: follow `account_requests_pagination` with `requests_pagination` until `has_more` is false. + +## Errors + +| Code | When | +| --- | --- | +| [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) | `account_id` with `operator`, or neither `account_id` nor `brand` + `operator`; a change that names a different brand `domain` or `brand_id`. | +| [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict) | A change's `revision` is stale, at the call or when an `in_review` change resolves; or the request was superseded by a newer change request from the same caller (`details.superseded_by`). | +| [`SCOPE_INSUFFICIENT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-scope-insufficient) / [`READ_ONLY_SCOPE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-read-only-scope) / [`FIELD_NOT_PERMITTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-field-not-permitted) | The caller's grant on the account doesn't permit the change, or doesn't cover a money field. | +| [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) (money change) | A change to `billing`, `billing_entity`, or `payment_terms` without `revision`. | +| [`BILLING_NOT_SUPPORTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-billing-not-supported) | The requested `billing` isn't in the seller's `supported_billing`. Sellers MAY instead record the request and reject it. | +| [`IDEMPOTENCY_CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-idempotency-conflict) | The `idempotency_key` was replayed with a different body. | +| [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) | The seller doesn't support account requests; or a change names `currency` or `timezone` and the seller doesn't list it in `changeable_attributes`; or a change names `sandbox`. | diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts.mdx new file mode 100644 index 00000000000..9014d9fd621 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts.mdx @@ -0,0 +1,734 @@ +--- +title: sync_accounts +description: "sync_accounts provisions buyer-declared accounts or updates settings on existing accounts with an AdCP seller agent." +"og:title": "AdCP — sync_accounts" +testable: false +--- + + +**Superseded where the seller supports `account.requests`** ([#8543](https://github.com/adcontextprotocol/adcp/issues/8543)). There, open and change accounts with [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account), read accounts and their requests with [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts), and, where the seller supports them (`account.status_changed` in `caller_event_types`, `media_buy.reporting_subscriptions`), register webhooks and reporting on the principal with [`sync_principal`](/dist/docs/3.3.0-beta.3/protocol/sync_principal). `sync_accounts` stays stable and available in 3.3, and it is the path with sellers that don't support `account.requests`. Its formal deprecation follows `account.requests` leaving experimental status. + +An entry that carries only `notification_configs` and/or `reporting_delivery_configs` is a **subscription write**. It MUST name an account the caller already holds (otherwise [`ACCOUNT_NOT_FOUND`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found)), and the seller MUST NOT provision, run account review, or change account attributes for it. A subscription write does not advance the account's `revision`. + + +Sync advertiser accounts with a seller for one or more advertiser/operator relationships, or update settings on existing accounts when the seller exposes that mode. A provisioning entry names the account's parties, a brand and an operator. It also sets the account's attributes, such as the countries it serves, an operator-owned unit, a fixed currency, a timezone, and sandbox status. The seller returns the `account_id` that identifies the account from then on. + +`sync_accounts` is used across all seller protocols: media buy agents, signals agents, governance agents, and creative agents. In provisioning mode it declares the buyer's intent — the seller provisions or links accounts internally. Use provisioning mode for buyer-declared accounts (`require_operator_auth: false`). Sellers MUST return the seller-assigned `account_id` on every non-failed provisioning result once the account is resolved, and accept it on every account-scoped task. A seller never issues an `account_id` it might later change: a `pending_approval` row that may still resolve to a different account carries an experimental `request_id` instead. Buyers send `{ "account_id": "..." }` on subsequent requests. Sellers keep accepting the natural-key `AccountRef` throughout 3.x. Using it as the reference after the account opens is deprecated as of 3.3 and planned for removal in 4.0; see [Account identity](/dist/docs/3.3.0-beta.3/accounts/overview#account-identity). For account-id namespaces, discover seller-assigned account IDs via [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) or out-of-band onboarding; `sync_accounts` provisioning for account-id namespaces is out of scope unless a future explicit capability declares that mode. If such a seller exposes `sync_accounts` today, use only settings-update mode keyed by `account_id`. + +For a complete `pending_approval` flow — capability discovery, human setup, durable status notification, authoritative repair, and first spend — see [Provision a seller-mediated account](/dist/docs/3.3.0-beta.3/accounts/provisioning-walkthrough). + +When the seller advertises `account.requests` (experimental in 3.3), prefer [`request_account`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account) for provisioning. Its requests can resolve to an existing account without changing an `account_id`. Provisioning rows here are then a projection of the same request; see [Relation to `sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account#relation-to-sync_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.3.0-beta.3/account/sync-accounts-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-request.json) +**Response Schema**: [`/schemas/3.3.0-beta.3/account/sync-accounts-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-accounts-response.json) + +## Quick start + +Sync a single advertiser account and check the resulting status: + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { SyncAccountsResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.syncAccounts({ + accounts: [ + { + brand: { domain: "acme-corp.com" }, + operator: "acme-corp.com", + billing: "operator", + }, + ], +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = SyncAccountsResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +for (const account of validated.accounts) { + console.log(`${account.brand.domain}: ${account.status}`); + if (account.status === "pending_approval" && account.setup?.url) { + console.log(` Complete setup at: ${account.setup.url}`); + } +} +``` + +```python Python +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, the seller deactivates accounts this agent previously synced that this request does not include. An account counts as included only when an entry names it by `account` or the seller returns its `account_id` for an entry. A provisioning entry can open an account, so list each account you keep as an `{ "account": { "account_id": "..." } }` entry. 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 | +|-------|------|----------|-------------| +| `account` | object | Settings update | Existing account to update, selected by `account_id`, or by a natural key that matches exactly one account. Its presence selects settings-update mode and prevents provisioning side effects. | +| `revision` | integer | With `operator_identity` | Expected current account revision in settings-update mode. It is required with the deprecated `operator_identity` and optional for every other settings update. Sellers reject stale revisions atomically with [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict). Obtain it from [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) or the latest `sync_accounts` result. | +| `operator_identity` | object | No | **Deprecated in 3.3.** Complete desired operator identity in settings-update mode. Requires the deprecated `account.identity_updates.supported: true`. Set `operator_unit` directly instead; see [Updating account attributes](#updating-account-attributes). | +| `destination_billing_entity` | object | Operator-billed domain handoff | **Deprecated in 3.3** with `operator_identity`. Complete staged billing identity for the destination operator. Required when changing `operator` on an account with `billing: "operator"`; otherwise absent. It is write-only while approval is pending and does not replace the canonical `billing_entity` until the handoff applies. | +| `brand` | object | Provisioning | Brand reference identifying the advertiser. `domain` and optional `brand_id` identify the brand. `countries[]`, a set of ISO 3166-1 alpha-2 codes, is an account attribute; it does not target delivery. In settings-update mode, `brand` updates `countries[]` and its `domain` and `brand_id` must equal the account's. The existing 3.x task still accepts broader BrandRef fields for compatibility, but per-call fields such as `industries`, `data_subject_contestation`, and `brand_kit_override` never affect lookup or account creation. New producers should send only BrandKey fields. | +| `operator` | string | Provisioning | 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. | +| `operator_unit` | object | No | Operator-owned business unit, agency seat, or buying-platform account. An account attribute: `id` is the operator-assigned identifier and optional `name` is display metadata. This is not the seller's `account_id`. | +| `currency` | string | No | ISO 4217 transaction currency when the seller's advertiser object is currency-bound. Check `account.supported_account_currency_modes`: include `currency` for `fixed`; omit it for `per_media_buy`. If an older 3.x seller omits that capability, its currency model is not discoverable and must be established out of band. An account attribute set when the account opens: every media buy on the account must use it. See [Updating account attributes](#updating-account-attributes). | +| `timezone` | string | Conditional | Operational timezone, an account attribute set when the account opens. Check `get_adcp_capabilities.account.timezone`: include one of `supported_timezones` when mode is `account_fixed` and selection is `buyer_selected`; omit it for `seller_fixed` or `seller_assigned`. The seller echoes the effective timezone on `sync_accounts` and `list_accounts`. | +| `billing` | string | Provisioning | Who the seller invoices for this buyer–storefront account relationship: `operator`, `agent`, or `advertiser`. An account attribute: a settings-update entry changes it on the same account. Check [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) for `supported_billing`. The seller must accept the requested invoiced party exactly or reject the request. This field does not choose a payment rail, clearing intermediary, or per-media-buy settlement route; AdCP payment and settlement remain out of protocol. Sellers MAY additionally reject a seller-wide supported value when the calling buyer agent's commercial relationship does not permit it. See [`BILLING_NOT_SUPPORTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-billing-not-supported), [`BILLING_NOT_PERMITTED_FOR_AGENT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent), [Buyer-agent identity](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#buyer-agent-identity), and [Billing and Account Setup](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling#billing-and-account-setup). | +| `billing_entity` | object | No | Structured business entity details for the account's current invoiced party. 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. Do not use this field to stage a destination operator during a handoff; use `destination_billing_entity`. See [billing entity and invoice recipient](/dist/docs/3.3.0-beta.3/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. An account attribute set when the account opens; it never changes. Only applicable to buyer-declared accounts (`require_operator_auth: false`). For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) or supplied out-of-band. | +| `notification_configs` | array | No | Account-level webhook subscribers for events that outlive any single media buy: creative lifecycle notifications, account status changes, and wholesale feed change webhooks. Omit to leave existing subscribers unchanged; send `[]` to remove all subscribers; send a full array to replace. Entries are keyed by account-scoped `subscriber_id`; an existing `subscriber_id` is upserted, and persisted IDs absent from the sent array are removed. | +| `reporting_delivery_configs` | array | No | Experimental caller/account-owned durable reporting desired state. Each entry selects one atomic capability `offering_id` and its exact `report_definition_id`, chooses `delivery_only` or `consumer_receipt` reconciliation, and creates an immutable semantic `(delivery_config_id, delivery_config_version)` generation; only lifecycle intent may change in place. Billing uses `consumer_receipt`. A billing generation follows the subscription continuity rules: a new generation or an account timezone change is a cutover that owes a stub period when the schedules' boundaries don't meet, and the seller delivers on a buy only inside its flight ([billing stubs](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_reporting_status#which-periods-are-owed)). Omit to preserve the caller's set; send `[]` to deactivate it. Destination coordinates are secret-free, and a caller-scoped immutable `destination_ref` may be reused across separately authorized accounts. A `delivery_config_id` equal to one of the caller's [reporting subscription](/dist/docs/3.3.0-beta.3/protocol/sync_principal#reporting-subscriptions) `subscription_id` values is rejected, and an active entry takes precedence over the caller's own subscriptions for this account and its `feed_purpose`, never another caller's. See [`get_reporting_status`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_reporting_status). | + +**Account identity**: The seller-assigned `account_id` identifies the account. `brand.domain`, optional `brand.brand_id`, and `operator` name the account's parties. `brand.countries`, `operator_unit`, `currency`, `timezone`, and `sandbox` are account attributes: an entry sets them, and they never change which account the `account_id` names. Country order has no meaning, and per-call BrandRef fields never affect lookup or account creation. + +An entry without `account` opens an account. A retry with the same `idempotency_key` returns the original result instead of opening a second account. + +Sending a provisioning entry again is safe. When the entry matches exactly one account in the seller's [account scope](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-scope), the seller SHOULD return that account's `account_id` instead of opening another, so a buyer that lost its idempotency state doesn't open a duplicate. The entry matches as a natural-key reference does: on `brand` and `operator`, with each attribute it supplies as a filter; see [Natural key semantics](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#natural-key-semantics). This is the match the [`request_account` deduplication guidance](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account#deduplication) recommends, and billing terms never filter. To open a second account for the same brand, such as one per market, send an entry whose attributes differ, such as its `countries`. + +A provisioning entry never changes an existing account. When the seller returns an existing account, the result row echoes the account's own attributes, and the exact-or-reject rules for `billing` and `payment_terms` still apply. To change an attribute, use settings-update mode. + +For example, the following fields describe Nova's account for Germany and the Netherlands, operated through Pinnacle's EMEA seat and fixed to EUR: + +```json +{ + "brand": { + "domain": "nova-athletics.example", + "countries": ["DE", "NL"] + }, + "operator": "pinnacle-media.example", + "operator_unit": { + "id": "seat_emea_01", + "name": "EMEA" + }, + "currency": "EUR" +} +``` + +Don't put an operator's buying-platform account ID in `account_id`. That value belongs in `operator_unit.id`; `account_id` is the seller's identifier. + +### Updating account attributes + +To change an attribute, such as `brand.countries` or `billing`, send a settings-update entry that names the account by `account_id`. The following entry changes the countries: + +```json +{ + "idempotency_key": "attribute-update-018f24c0-65d1-7c3a", + "accounts": [ + { + "account": { "account_id": "acc_nova_pinnacle" }, + "revision": 7, + "brand": { + "domain": "nova-athletics.example", + "countries": ["BE", "DE", "NL"] + } + } + ] +} +``` + +The seller applies the change to the same account, which keeps its `account_id`, media buys, and reporting history. The seller validates a changed attribute as it does when the account opens, and rejects the entry with the same error. Authorization, such as the brand-operator check, applies to the new values. The entry's `brand.domain` and `brand.brand_id` must equal the account's: a different brand is a different account, and the seller rejects the entry with [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request). A seller that does not support changing an attribute rejects the entry with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) and leaves the account unchanged. + +`currency`, `timezone`, and `sandbox` are set when the account opens. A seller MAY refuse a `currency` or `timezone` change with `UNSUPPORTED_FEATURE`, and changing one normally means opening a new account. `sandbox` never changes: the seller refuses a different `sandbox` value with `UNSUPPORTED_FEATURE`. + +A seller can apply a `countries` change, refuse it, or review it first. To review it, the seller reports `action: "unchanged"` and `status: "pending_approval"` on the entry's result row, and `setup` can explain the review. The account keeps its current countries until the seller decides, and `list_accounts` reports the account's own status. + +A seller that supports experimental `account.requests` MUST put the review's `request_id` on that row. Read the request through [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with `request_ids`, or wait for `account_request.changed`. It resolves the way a [`request_account` change](/dist/docs/3.3.0-beta.3/accounts/tasks/request_account#changing-an-account) without `allow_separate_account` does: `updated` when the seller applies the change, `matched` when it points to an account the caller already holds, or `rejected` with the reason. A settings update never opens a separate account. A seller that would accept the change only on a new account rejects it with [`ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-change-requires-new-account), either on the row (`action: "failed"`) or on the request after review, and the buyer opens a new account with a provisioning entry if it wants one. For example: + +```json +{ + "accounts": [ + { + "account": { "account_id": "acc_nova_pinnacle" }, + "action": "unchanged", + "status": "pending_approval", + "request_id": "areq_nova_be_market", + "setup": { + "message": "Adding BE requires a market review." + } + } + ] +} +``` + +A settings-update result row echoes `account` and does not carry `brand`. To confirm a `countries` change, read the account again through [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts). Without a `request_id`, the account shows its current countries both while the review runs and after a refusal, so only the request tells the two apart. + +`countries` declares the markets the account serves. What a seller may use them for, what a change does to accepted media buys, and what happens when a change makes two accounts alike are set out in [Account identity](/dist/docs/3.3.0-beta.3/accounts/overview#account-identity). + +`revision` is optional and guards the write. When it is stale, the seller returns [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict) without side effects. Every persisted account change increments `revision`; reads, dry runs, validation failures, and exact idempotency replays do not. + +An attribute update never changes the account's identity, so no former reference exists to retire: the `account_id` still names the account. + + + **Deprecated in 3.3: `operator_identity` reconciliation.** The `operator_identity` and `destination_billing_entity` request fields are deprecated. So are the `identity_change` and `identity_change_preview` response fields and the `account.identity_updates` capability. The [`ACCOUNT_MOVED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-moved) and [`ACCOUNT_IDENTITY_CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-identity-conflict) error codes are deprecated too. All of them are planned for removal in 4.0. Sellers that implement them keep them working through 3.x under the [3.2 `sync_accounts` rules](/dist/docs/3.2.6/accounts/tasks/sync_accounts#reconciling-operator-identity). To change `operator_unit`, set it as an attribute. A different operator domain is a different party: that operator opens its own account. + + +## 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 | +|-------|-------------| +| `account` | Present only on settings-update-mode results, echoed from the request's `account` field. Anchors this row's identity via `account_id` or natural key in place of `brand`/`operator` — including on `action: "failed"` results, where the account may not resolve to a brand/operator tuple at all. See [Two modes](#two-modes-provisioning-vs-settings-update). | +| `account_id` | Seller-assigned account identifier and the account's identity. A seller never issues an `account_id` it might later change. Sellers MUST return it on every non-failed provisioning result, except a `pending_approval` row that may still resolve to a different account, which carries `request_id` and no `account_id`. Sellers accept it as an `AccountRef` on every account-scoped task, and it never changes for the life of the account. | +| `request_id` | Experimental (`account.requests`). The seller's account request behind a `pending_approval` row that has no `account_id` yet. Read it with [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) (`request_ids`). Never an `AccountRef`. | +| `resolution` | Experimental (`account.requests`). On a resolved row: `matched` (an existing account was linked; `action: unchanged`) or `created`. | +| `brand` | Current canonical brand. Object with `domain`, optional `brand_id`, and optional `countries[]`. Present on provisioning-mode results. | +| `operator` | Current operator domain. While a deprecated identity change is pending or rejected, this remains the current value. | +| `operator_unit` | Current operator unit, an account attribute. `name` is display metadata. | +| `revision` | Current optimistic-concurrency revision. Pass it on the next settings update. | +| `identity_change` | **Deprecated in 3.3.** Pending or rejected desired operator identity. Current values remain in `operator` and `operator_unit`. | +| `identity_change_preview` | **Deprecated in 3.3.** Dry-run-only disposition and resource-impact preview. Current values and revision remain unchanged. | +| `currency` | Fixed account currency, echoed when supplied. | +| `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)). On a settings-update row, `pending_approval` means the seller is reviewing the entry's `countries` change. Required except when `action: "failed"`, where the seller may not know a lifecycle state to report. | +| `billing` | Billing model applied. Matches the requested value. | +| `billing_entity` | Current canonical business entity for the invoiced party. During a pending operator-domain handoff, this remains the current entity; the staged `destination_billing_entity` is write-only. Sellers may add verified fields, but bank details are always omitted. | +| `account_scope` | How the seller scoped this account: `operator` (shared across brands for this operator), `brand` (shared across operators for this brand), `operator_brand` (dedicated to this operator+brand pair), or `agent` (agent-scoped account shared across declared brand/operator pairs). See [account scope](/dist/docs/3.3.0-beta.3/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`. Experimental `waiting_on` (`seller` or `buyer`) says whose turn it is, and `kind` says what is needed (`review`, `sign_up`, `accept_terms`, `credit_application`, `add_payment`, `grant`, `connect_credentials`). | +| `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. Required and non-empty when `action: "failed"`; absent otherwise. | +| `warnings` | Non-fatal notices. | +| `sandbox` | Whether this is a sandbox account, echoed from the request. Only present for buyer-declared accounts. | +| `notification_configs` | Optional. Current persisted account-level webhook subscribers after applying the request. Present on `created`, `updated`, and `unchanged` results when the request included `notification_configs` or the account already has persisted subscribers. Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`; `authentication.credentials` is omitted (write-only). | +| `authorization` | Optional. The calling agent's scope grant for this account — `allowed_tasks`, `field_scopes`, `scope_name`, `read_only`. Applies to every vendor agent type (media-buy, signals, governance, creative, brand) — the Accounts Protocol surface is shared. Present on `created`, `updated`, and `unchanged` results; omitted on `failed` results. Vendor agents that support scope introspection SHOULD populate this; media-buy sales agents claiming the `attestation_verifier` standard scope MUST populate it. Absence means the vendor agent does not advertise introspectable scope; callers MUST NOT infer access from absence. See [Caller authorization](/dist/docs/3.3.0-beta.3/accounts/overview#caller-authorization) for the full shape. | + +### Account status + +| Status | Meaning | Next step | +|--------|---------|-----------| +| `active` | Ready to use | Use [account reference](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references) in protocol operations | +| `pending_approval` | Seller reviewing the account or a `countries` change on a settings-update row, or waiting on a buyer-side human | `setup.waiting_on`, when present, says which. Human may need to visit `setup.url` to complete credit or legal process. Poll [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) for updates, or [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with `request_ids` when the row carries `request_id`. | +| `rejected` | Seller declined the request | Review rejection reason in `warnings`, adjust and retry, or contact seller | +| `payment_required` | Credit limit reached or funds depleted | Add funds or increase credit limit. Route spend to other accounts. | +| `suspended` | Was active, now paused | Contact seller to resolve | +| `closed` | Was active, now terminated | -- | + +### Async notifications + +When `push_notification_config` is provided and the seller returns `pending_approval`, the seller sends a webhook notification when the account status changes (e.g., approved → `active`, declined → `rejected`). + +For provisioning requests, the notification payload includes `brand`, `operator`, and the seller-assigned `account_id`, so the buyer can correlate it to the original sync request. The buyer uses that `account_id` on subsequent calls. + +```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.3.0-beta.3/accounts/tasks/list_accounts) to check for status changes. + +## Two modes: provisioning vs. settings-update + +Each per-account entry uses one of two key shapes, never both: + +- **Provisioning mode** — flat `brand` + `operator` + `billing` at the entry root. The seller opens an account and MUST return its `account_id` once the account is resolved. When the entry matches exactly one account in its account scope, the seller SHOULD return that `account_id` instead; see [Request parameters](#request-parameters). Used for buyer-declared accounts (`require_operator_auth: false`). This is the shape AdCP 3.0 shipped with. The natural-key `AccountRef` remains valid for subsequent calls throughout 3.x. +- **Settings-update mode** — `account` (an [AccountRef](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references)) at the entry root, with `operator` absent. The seller updates the account's settable state and attributes, `brand.countries` and `billing` included — no provisioning side effects. A seller that opens buyer-declared accounts through `sync_accounts` accepts settings-update entries keyed by `account_id`, and declines an attribute change it cannot make with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature). For account-id namespaces, the mode applies only when the seller exposes settings updates through this task. Upstream-managed accounts are discovered via [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts), while seller-defined account IDs may be supplied out-of-band. + +Schema enforces the exclusivity via `oneOf` — sending both shapes on the same entry is a validation error. An account-id-namespace seller that doesn't expose settings updates rejects `account`-keyed entries with [`UNSUPPORTED_PROVISIONING`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-provisioning). Sellers that don't provision through `sync_accounts`, including account-id namespaces, reject natural-key provisioning entries with the same code. + +The response result row mirrors the request's discriminator, and the two identity shapes are mutually exclusive: a settings-update-mode result echoes `account` instead of `brand`/`operator`, since an account-id-namespace seller frequently has no buyer-declared brand/operator tuple to return at all. This also makes `action: "failed"` settings-update results representable — for example, an `account_id` the seller cannot reach has no brand/operator tuple to resolve, and the row's identity is carried entirely by the echoed `account` instead. `status` is required on every non-failed result, but optional on `failed` results: some failures still know the account's lifecycle state (e.g. a rejected identity change on an otherwise-reachable account), while others — like this one — do not, and sellers MUST NOT invent a value; `errors` is required instead: + +```json +{ + "account": { "account_id": "acct-social-unreachable" }, + "action": "failed", + "errors": [ + { "code": "ACCOUNT_NOT_FOUND", "message": "Account does not exist or is not accessible to the authenticated agent." } + ] +} +``` + +## Account-level webhook subscriptions + +`notification_configs[]` carries account-level webhook subscribers for creative lifecycle and assignment changes, `indicators.changed`, account status, wholesale feed changes, and future account-anchored events. + +Indicator and assignment subscriptions are prospective: activation or reactivation does not replay conditions that were already current. After activation, buyers establish a complete baseline through [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys) before relying on later invalidations: enumerate known media-buy IDs or request every media-buy status and follow pagination to exhaustion, without an indicator filter. The optional bounded [`list_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives) projection is not sufficient to prove complete state. + +`account.status_changed` is the durable account lifecycle event. Use it for status transitions after the initial `sync_accounts` result, such as `pending_approval → active`, `pending_approval → rejected`, `active → payment_required`, `active → suspended`, recovery back to `active`, or terminal `closed`. It is not a replacement for the one-shot `push_notification_config` on this task: that channel can report the async result of the original provisioning operation, while `notification_configs[]` subscribers outlive the task. + +Before relying on durable account lifecycle webhooks, read `get_adcp_capabilities.account.notifications`. Sellers that declare `supported: true` accept `account.status_changed` registrations here, name `sync_accounts` as the registration task, and name [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) as the repair read. Sellers that omit the capability or declare `supported: false` MUST reject `account.status_changed` registrations instead of silently storing a subscriber that will never fire. + +### Account change feed notifications + +`account.change_recorded` is the generic wake-up for the optional durable +[`list_account_changes`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes) feed. It +covers committed material changes from every origin within the seller's +advertised resource coverage, including seller surfaces, connected platforms, +seller automation, and other authorized principals. Before accepting this +event type, the seller MUST advertise +`get_adcp_capabilities.account.change_feed.supported: true` and list the +[experimental](/dist/docs/3.3.0-beta.3/reference/experimental-status) feature id +`account.change_feed` in `experimental_features`. + +Each feed record produces one logical fire for every active subscriber that +requested `account.change_recorded`. The fire's `notification_id` equals the +record's `change_id`. The payload is only an invalidation: receivers drain from +their own persisted cursor and then invoke the record's repair task. Its +optional `through_cursor` is a target watermark, never a checkpoint to install +without reading intervening pages. Existing specialized notifications may +overlap this generic fire. + +For these event types, "wholesale feed" means the seller's buyable wholesale product and signals feeds returned by [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products) or [`get_signals`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals); it is not the buyer-provided feeds managed by [`sync_catalogs`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_catalogs). + +Permitted in **both** provisioning and settings-update modes. Declarative semantics: + +- Omit `notification_configs` to leave the account's existing subscribers unchanged. +- Send `notification_configs: []` to remove every subscriber on that account. +- Send a non-empty array to replace the account's current set with the submitted set. + +Within one account, `subscriber_id` is the stable logical key. Re-sending an existing `(account_id, subscriber_id)` with a different `url`, `event_types`, `authentication`, or `active` value replaces that subscriber's active config rather than creating a duplicate. The seller MUST NOT merge the submitted array with persisted state: persisted subscribers whose `subscriber_id` does not appear in the sent array are removed. Paused entries (`active: false`) are subject to the same replacement semantics; to preserve a paused subscription, re-include it with `active: false` in the sent array. Duplicate `subscriber_id` values within the same submitted array are invalid. The replacement is account-scoped; the same `subscriber_id` MAY be reused on a different account. + +If any entry in the submitted replacement set fails validation or activation proof, the seller rejects that account entry with `action: "failed"` and leaves the account's previous `notification_configs[]` set unchanged. Sellers MUST NOT partially apply a replacement set and silently drop only the failed subscriber. + +Each entry has: + +- `subscriber_id` — buyer-supplied identifier, unique within the account; echoed on every fire so multi-subscriber accounts can route by endpoint +- `url` — HTTPS endpoint URL. Sellers MUST complete an endpoint activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active. +- `event_types[]` — types the subscriber wants. Only account-anchored types are permitted (today: `creative.status_changed`, `creative.assignment_changed`, `indicators.changed`, `creative.purged`, `account.status_changed`, `account.change_recorded`, `product.created`, `product.updated`, `product.priced`, `product.removed`, `signal.created`, `signal.updated`, `signal.priced`, `signal.removed`, `wholesale_feed.bulk_change`, the experimental `proposal.offered`, and the experimental `collection_card.updated`, `collection_card.removed` and `collection_catalog.bulk_change`, which a buyer registers only with a seller that lists them in `wholesale_feed_webhooks.event_types`; see [`list_collections`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/list_collections#collection-card-webhooks)). Sellers MUST reject any media-buy-anchored type (`scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or agent-anchored type (`capabilities.changed`) as a per-account validation failure with [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) or [`VALIDATION_ERROR`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-validation-error) in `accounts[].errors[]`, and `error.field` MUST point at the invalid `event_types` entry. +- `product_payload_view` — `canonical` for a [`list_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/list_products) mirror or `legacy` for the 3.x `get_products` shape. Omission defaults to `legacy`; valid only when a product event is selected. Sellers echoing configs MUST NOT add `product_payload_view` to a config that selects no product event. +- `authentication` (optional) — legacy Bearer or HMAC-SHA256. Omit to use the default RFC 9421 webhook profile. A seller with `legacy_hmac_fallback: false` rejects an entry that carries it with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) in `accounts[].errors[]`. When present, the same signed-registration downgrade-resistance rules as `push_notification_config.authentication` apply. Credentials are write-only — sellers omit them on reads. +- `active` (default `true`) — set `false` to pause a subscriber without removing the registration. Sellers MAY skip only the outbound proof challenge while `active: false`; they MUST still enforce HTTPS parsing, hostname normalization, and reserved-range rejection on write. Paused subscribers MUST NOT receive fires until reactivated. Reactivation MUST repeat full SSRF validation with connect pinning plus proof-of-control for any tuple without current valid proof. + +### Endpoint proof of control + +Before persisting or echoing an entry as `active: true`, the seller MUST validate the URL, apply the SSRF rules in [Webhook URL validation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#webhook-url-validation-ssrf), and prove that the receiver controls the endpoint. + +Proof is required when there is no current valid proof for the tuple `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)`. Changing the subscriber ID, normalized URL, authentication mode/credential binding, or `event_types[]` requires fresh proof before the new set can become active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key even when the candidate config selects legacy delivery auth. New signers use `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. The receiver MUST verify the RFC 9421 signature and MUST reject the challenge unless `account_id`, `subscriber_id`, `seller_agent_url`, `delivery_auth`, and `event_types` match the pending registration. The signature's covered `@target-uri` MUST equal the exact normalized candidate URL, and its `expires` parameter binds the challenge expiry. + +The standard challenge is an HTTPS POST to the candidate `url` with a JSON body containing `type`, `challenge`, `account_id`, `subscriber_id`, `seller_agent_url`, `delivery_auth`, and `event_types`. The canonical schemas are [`webhook-challenge.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/webhook-challenge.json) and [`webhook-challenge-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/webhook-challenge-response.json). + +```json +{ + "type": "webhook.challenge", + "challenge": "example-challenge-token-000000000000", + "account_id": "acct_123", + "subscriber_id": "buyer-primary", + "seller_agent_url": "https://seller.example/adcp", + "delivery_auth": { "mode": "rfc9421" }, + "event_types": ["creative.status_changed"] +} +``` + +The receiver proves control by returning HTTP `2xx` with a JSON body containing exactly one echo field: + +```json +{ "challenge": "example-challenge-token-000000000000" } +``` + +Sellers MUST also accept the backward-compatible alias: + +```json +{ "token": "example-challenge-token-000000000000" } +``` + +The challenge value MUST be cryptographically random, single-use, and scoped to the registration tuple. The RFC 9421 `expires` parameter is the challenge expiry; receivers MUST reject expired challenges, and sellers MUST reject echoes received after that instant. A failed, non-`2xx`, malformed, mismatched, expired, replayed, or timed-out challenge means proof failed. Sellers SHOULD use the same outbound fetch caps as SSRF validation (10 second connect and 10 second read) and SHOULD make at most one initial challenge POST on the `sync_accounts` critical path. A seller MAY retry one transient network failure before returning if it uses the same challenge value and still completes within its request budget; otherwise the buyer retries by re-sending `sync_accounts`. + +On proof failure, the seller returns a per-account failure with `action: "failed"`, `errors[].code: "VALIDATION_ERROR"` (or `INVALID_REQUEST` for malformed URLs), and `error.field` pointing at `accounts[i].notification_configs[j].url`. The previous persisted subscriber set remains unchanged. `dry_run: true` MUST NOT send network challenges; an `active: true` echo in a dry-run result previews the requested post-proof state and does not assert that proof already exists. + +### Account status payload + +When an account's lifecycle status changes after the initial `sync_accounts` response, sellers fire `account.status_changed` to active subscribers whose `event_types[]` includes that value. + +```json +{ + "idempotency_key": "whk_01K18GM0Z7J3Q6WBH7DYK2R4VM", + "notification_id": "acctchg_acc_glow_20260719T100712Z", + "notification_type": "account.status_changed", + "fired_at": "2026-07-19T10:07:15Z", + "subscriber_id": "account-lifecycle", + "account_id": "acc_glow_pending", + "previous_status": "pending_approval", + "status": "payment_required", + "observed_at": "2026-07-19T10:07:12Z", + "reason_code": "setup_required", + "setup": { + "message": "Complete advertiser billing setup.", + "expires_at": "2026-07-30T00:00:00Z" + } +} +``` + +The payload is an invalidation signal. It intentionally omits the full account document and `setup.url`; setup URLs are often single-use or sensitive and should not be fanned out to every subscriber. Receivers SHOULD re-run [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) for `account_id` and reconcile from the fresh account snapshot. + +`notification_id` is stable per `(account_id, previous_status, status, observed_at)`. Retries and re-emissions of the same transition reuse it under a new `idempotency_key`; a later transition cycle receives a new `notification_id`. + +Example — register a buyer-side endpoint plus an audit bus on an account-id namespace account: + +```json +{ + "idempotency_key": "f2c4b7d9-6789-49bc-defa-2345678901bc", + "accounts": [ + { + "account": { "account_id": "acc_acme_pinnacle" }, + "notification_configs": [ + { + "subscriber_id": "buyer-primary", + "url": "https://buyer.example/webhooks/adcp/creative", + "event_types": ["creative.status_changed", "creative.purged"], + "active": true + }, + { + "subscriber_id": "audit-bus", + "url": "https://audit.buyer.example/adcp/ingest", + "event_types": ["creative.status_changed", "creative.purged"], + "active": true + } + ] + } + ] +} +``` + +Example — register a wholesale feed mirror subscriber for wholesale product and signal changes: + +```json +{ + "idempotency_key": "a8af8cf1-89bd-41f3-b27d-7ee7e9f8d2e4", + "accounts": [ + { + "account": { "account_id": "acc_acme_pinnacle" }, + "notification_configs": [ + { + "subscriber_id": "wholesale-feed-sync", + "url": "https://buyer.example/webhooks/adcp/wholesale-feed", + "event_types": [ + "product.created", + "product.updated", + "product.priced", + "product.removed", + "signal.created", + "signal.updated", + "signal.priced", + "signal.removed", + "wholesale_feed.bulk_change" + ], + "active": true + } + ] + } + ] +} +``` + +Example — register an account lifecycle subscriber: + +```json +{ + "idempotency_key": "ee1343bb-7449-4d33-a2c1-c9cc0c75d0f4", + "accounts": [ + { + "account": { "account_id": "acc_acme_pinnacle" }, + "notification_configs": [ + { + "subscriber_id": "account-lifecycle", + "url": "https://buyer.example/webhooks/adcp/accounts", + "event_types": ["account.status_changed"], + "active": true + } + ] + } + ] +} +``` + +Governance agents registered via [`sync_governance`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) are **not** implicitly subscribed to these webhooks. If your governance agent should also receive creative-lifecycle fires, register its URL as a separate `notification_configs[]` entry — explicit, auditable, with its own `event_types[]` filter. + +Verify applied state via [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) — the response carries the current persisted `notification_configs[]` per account with credentials redacted. `sync_accounts` also echoes the current sanitized set on `created`, `updated`, and `unchanged` results when the request included `notification_configs` or any persisted subscribers already exist. + +Wholesale feed notifications are registered here, not through a separate subscription task. The webhook body is [`wholesale-feed-webhook.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/wholesale-feed-webhook.json): it carries a canonical product replacement for `list_products` subscribers (or the legacy Product view for `get_products` subscribers), signal changes, or a bulk-change summary plus the post-change `wholesale_feed_version`. Sellers MUST apply the same per-subscriber authorization and scope predicate used by the corresponding wholesale read before emitting each webhook. For a detected gap, repair with the mirror's last applied version; for uncertain or bulk state, omit the conditional token and replace from `list_products` / `get_signals`. See [wholesale_feed_webhooks](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities#wholesale_feed_webhooks) for capability declaration and event semantics. + +## Common scenarios + +### Agency syncing multiple brands + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { SyncAccountsResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.syncAccounts({ + accounts: [ + { + brand: { domain: "nova-brands.com", brand_id: "spark" }, + operator: "pinnacle-media.com", + billing: "operator", + }, + { + brand: { domain: "nova-brands.com", brand_id: "glow" }, + operator: "pinnacle-media.com", + billing: "operator", + }, + ], +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = SyncAccountsResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +for (const account of validated.accounts) { + if (account.status === "active") { + console.log(`Ready: ${account.brand.domain}/${account.brand.brand_id} → ${account.status}`); + } else if (account.status === "pending_approval") { + console.log(`Setup required for ${account.brand.brand_id}: ${account.setup?.url}`); + // Poll list_accounts until status becomes active + } +} +``` + +```python Python +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/sdk/testing"; +import { SyncAccountsResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.syncAccounts({ + accounts: [ + { + brand: { domain: "acme-corp.com" }, + operator: "acme-corp.com", + billing: "operator", + }, + ], +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = SyncAccountsResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +const account = validated.accounts[0]; +if (account.status === "active") { + console.log(`Ready: ${account.brand.domain} — ${account.status}`); +} else if (account.status === "pending_approval") { + console.log(`Setup required: ${account.setup?.url}`); + // Poll list_accounts until status becomes active +} +``` + +```python Python +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/sdk/testing"; +import { SyncAccountsResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.syncAccounts({ + accounts: [ + { + brand: { domain: "acme-corp.com", brand_id: "clearance" }, + operator: "acme-corp.com", + }, + ], +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = SyncAccountsResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +for (const account of validated.accounts) { + if (account.status === "rejected") { + console.log("Account request was rejected"); + if (account.warnings?.length) { + console.log(`Reason: ${account.warnings.join(", ")}`); + } + } +} +``` + +```python Python +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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found) | Referenced account does not exist or is not accessible | Check `account_id` or re-sync | +| [`ACCOUNT_AMBIGUOUS`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-ambiguous) | A natural-key `account` matches more than one account | Retry with an `account_id` from `error.details.candidates` or [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) | +| [`ACCOUNT_MOVED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-moved) | **Deprecated in 3.3.** A former natural key was used after a deprecated `operator_identity` rekey | Replace the stale reference with `error.details.current_account`, refresh through [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts), and retry with a fresh idempotency key | +| [`CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-conflict) | Submitted `revision` is stale | Re-read `list_accounts`, reconcile, and retry with the current revision | +| [`ACCOUNT_IDENTITY_CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-identity-conflict) | **Deprecated in 3.3.** A deprecated `operator_identity` change targets another account's natural key | Choose a different operator identity or retain the current identity; do not retry the same desired identity unchanged | +| [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) | Seller does not support changing the requested attribute, or does not advertise the deprecated `operator_identity` change | Leave the attribute unchanged, or open a separate account with the attribute you need | +| [`BILLING_NOT_SUPPORTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-billing-not-supported) | Seller-wide capability gate (`supported_billing` does not include the value) or per-account-relationship gate; see [Billing and Account Setup](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling#billing-and-account-setup) | Check [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) for `supported_billing`, adjust or omit `billing`; inspect `error.details.scope` to disambiguate capability vs account scope | +| [`BILLING_NOT_PERMITTED_FOR_AGENT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-billing-not-permitted-for-agent) | Seller-wide capability accepts the value, but the calling buyer agent's commercial relationship does not (e.g., passthrough-only — no payments relationship); see [Buyer-agent identity](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) | Retry with `error.details.suggested_billing` (typically `operator`) when present; when absent, surface to a human — the agent cannot extend its own commercial relationship | +| [`PAYMENT_TERMS_NOT_SUPPORTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-payment-terms-not-supported) | Seller does not accept the requested payment terms | Omit `payment_terms` to accept the seller's default, or negotiate offline | +| [`ACCOUNT_PAYMENT_REQUIRED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-payment-required) | Account has an outstanding balance requiring payment | Resolve outstanding balance or route to another account | +| [`ACCOUNT_SUSPENDED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-suspended) | Account is suspended | Contact seller to resolve | +| [`BRAND_REQUIRED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-brand-required) | Billable operation attempted without brand reference | Include `brand` in the request | + +## Next steps + +- [list_accounts](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) -- Poll for status changes on pending accounts +- [sync_governance](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) -- Sync governance agents to accounts +- [Accounts and agents](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents) -- Billing models, trust models, and authorized operators +- [Brand protocol](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json) -- How seller agents resolve brand identity from the `brand.domain` +- [get_adcp_capabilities](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) -- Discover `supported_billing` and `require_operator_auth` before syncing accounts diff --git a/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance.mdx b/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance.mdx new file mode 100644 index 00000000000..4193526bcf4 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance.mdx @@ -0,0 +1,315 @@ +--- +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 the governance agent endpoint for specific accounts. The seller persists the agent and calls it via [`check_governance`](/dist/docs/3.3.0-beta.3/governance/campaign/tasks/check_governance) during media buy lifecycle events. Each account entry pairs an [account reference](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references) with exactly one governance agent, supporting both account-id namespaces (`account_id`) and buyer-declared accounts (`brand` + `operator`). + +An account binds to one governance agent that owns the full lifecycle. Authorization, delivery monitoring, and compliance are phases of the same evaluation against one plan, not specialisms held by separate authorities; specialist review (legal, brand safety, category) composes inside the governance agent rather than across multiple registrations. `governance_agents` is an array with `maxItems: 1` because the array shape is the shape 3.0 shipped with — the constraint is load-bearing and not a staging post toward loosening. The envelope's `governance_context` is singular below this layer; relaxing the cap would require a coordinated wire-shape change that is not planned. See [One governance agent per account](/dist/docs/3.3.0-beta.3/governance/campaign/specification#one-governance-agent-per-account). + +This uses **replace semantics** — each call replaces any previously registered agent on the specified accounts. Accounts not included in the request keep their existing configuration. + +## Seller acceptance of governance agents + +Sellers may publish seller-wide advisory criteria at `adcp.governance_enforcement.accepted_governance_agents` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities). `any_of[]` is a typed union: a candidate is acceptable when it satisfies at least one matcher. + +- `agent_url` matches one explicit HTTPS endpoint. +- `verification` requires a fresh result from the named HTTPS registry for the declared agent role, AdCP version, allowed verification modes, and maximum result age. + +URL matchers use the shared [AdCP URL canonicalization rules](/dist/docs/3.3.0-beta.3/reference/url-canonicalization), not raw string equality. They match the exact canonical endpoint; redirects, DNS aliases, and candidate assertions do not expand an allowlist. A verification matcher is satisfied only by a deterministic result from the seller-configured trusted registry. The candidate cannot nominate the registry, and its self-described role or verification state is never evidence. Registry resolution uses HTTPS, no redirects or forwarded credentials, public-network address checks, DNS pinning through connection, bounded responses, and authenticated registry records. If the registry or verification service cannot be resolved, the seller returns [`GOVERNANCE_UNAVAILABLE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-governance-unavailable); it does not guess or silently turn an availability problem into rejection. + +The capabilities declaration is seller-wide preflight guidance. The response to `sync_governance` is authoritative for each account because account contracts, jurisdictions, and product access may narrow the criteria. Verification evidence is evaluated for freshness at binding time and pinned to the accepted binding. Later capability or registry drift does not silently revoke or redirect an existing binding; an explicit account resynchronization creates a new decision. If the submitted agent does not satisfy the applicable criteria, the account result is `failed` with [`GOVERNANCE_AGENT_NOT_ACCEPTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-governance-agent-not-accepted). A seller may return either opaque details (`{"disclosure":"opaque"}` plus an optional local `rejection_ref`) or disclosed details containing the parsed HTTPS origin and authoritative criteria. The disclosed arm uses `attempted_agent_origin`, never the submitted URL: userinfo, path, query, and fragment are stripped after parsing. Buyers reconciling a disclosed rejection against their `any_of[]` matcher list should compare full matcher entries, not origins — an `agent_url` matcher may include a path and therefore be narrower than the disclosed origin. The seller MUST NOT persist or contact a rejected endpoint and MUST NOT echo credentials or a raw candidate URL in responses, logs, or error details. + +Acceptance matchers MUST express objective operational, security, interoperability, or compliance requirements. A seller MUST NOT use a URL allowlist or verification criterion to exclude a functionally equivalent governance provider merely as commercial leverage. Sellers SHOULD prefer verification criteria over enumerating providers when the registry can express the requirement deterministically. + +Absence of `accepted_governance_agents` means the seller does not restrict governance-agent identity through this mechanism and accepts any otherwise valid binding. Adding or changing a declaration does not retroactively invalidate existing bindings; the seller applies it when a buyer next binds or replaces an agent. Account-specific criteria returned on a failed sync override the advisory capability for that attempt. + +**Response Time**: ~1s. + +**Request Schema**: [`/schemas/3.3.0-beta.3/account/sync-governance-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-governance-request.json) +**Response Schema**: [`/schemas/3.3.0-beta.3/account/sync-governance-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-governance-response.json) + +## Quick Start + +Sync the governance agent for an account-id namespace account: + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { SyncGovernanceResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.syncGovernance({ + accounts: [ + { + account: { account_id: "acct-social-001" }, + governance_agents: [ + { + url: "https://governance.pinnacle-media.com", + authentication: { + schemes: ["Bearer"], + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + } + ] +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = SyncGovernanceResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +for (const entry of validated.accounts) { + if (entry.status === "synced") { + console.log(`${JSON.stringify(entry.account)}: ${entry.governance_agents.length} agent registered`); + } else { + console.log(`${JSON.stringify(entry.account)}: failed — ${JSON.stringify(entry.errors)}`); + } +} +``` + +```python Python +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.sync_governance( + accounts=[ + { + "account": {"account_id": "acct-social-001"}, + "governance_agents": [ + { + "url": "https://governance.pinnacle-media.com", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + } + ] + ) + + if hasattr(result, 'errors') and result.errors: + raise Exception(f"Operation failed: {result.errors}") + + for entry in result.accounts: + if entry.status == "synced": + print(f"{entry.account}: {len(entry.governance_agents)} agent registered") + else: + print(f"{entry.account}: failed — {entry.errors}") + +asyncio.run(main()) +``` + + + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|--------------| +| `accounts` | array | Yes | Per-account governance agent entries. Each pairs an account reference with governance agents for that account. | + +**Each account entry:** + +| Field | Type | Required | Description | +|-------|------|----------|--------------| +| `account` | object | Yes | [Account reference](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references): `{account_id}` for account-id namespaces or `{brand, operator}` for buyer-declared accounts. | +| `governance_agents` | array | Yes | Governance agent endpoint for this account. Array with exactly one entry (`minItems: 1`, `maxItems: 1`). | + +**The governance agent:** + +| Field | Type | Required | Description | +|-------|------|----------|--------------| +| `url` | string | Yes | HTTPS endpoint URL for the governance agent. | +| `authentication` | object | Yes | Credentials the seller presents when calling this agent. Contains `schemes` (array with one auth scheme) and `credentials` (token, min 32 characters). | + +## Response + +**Success response:** + +Returns an `accounts` array with per-account results. Individual entries may fail even when the operation succeeds. + +| Field | Description | +|-------|-------------| +| `account` | Account reference, echoed from request. | +| `status` | `"synced"` or `"failed"`. | +| `governance_agents` | Governance agents now active on this account. Reflects persisted state. Only present when `status: "synced"`. | +| `errors` | Per-account errors. Only present when `status: "failed"`. | + +For [`GOVERNANCE_AGENT_NOT_ACCEPTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-governance-agent-not-accepted), `errors[].details` conforms to `error-details/governance-agent-not-accepted.json`. It never contains authentication credentials. + +**Error response:** + +`errors` array with operation-level errors (auth failure, service unavailable). No `accounts` array is present. + +## Authorization + +The seller MUST verify that the authenticated agent has authority over each referenced account before persisting governance agents. Requests referencing accounts the agent does not own MUST return a `failed` status with an error for those entries. + +## Common Scenarios + +### Different governance agents per account + +A single `sync_governance` call can register a distinct agent per account — each account still binds to exactly one agent, but accounts on the same call need not share it. + + + +```javascript JavaScript +import { testAgent } from "@adcp/sdk/testing"; +import { SyncGovernanceResponseSchema } from "@adcp/sdk"; + +const result = await testAgent.syncGovernance({ + accounts: [ + { + account: { account_id: "acct-social-001" }, + governance_agents: [ + { + url: "https://governance.pinnacle-media.com", + authentication: { + schemes: ["Bearer"], + credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + }, + { + account: { account_id: "acct-social-002" }, + governance_agents: [ + { + url: "https://governance.acme-buyer.com", + authentication: { + schemes: ["Bearer"], + credentials: "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + } + } + ] + } + ] +}); + +if (!result.success) { + throw new Error(`Request failed: ${result.error}`); +} + +const validated = SyncGovernanceResponseSchema.parse(result.data); + +if ("errors" in validated && validated.errors) { + throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); +} + +for (const entry of validated.accounts) { + console.log(`${JSON.stringify(entry.account)}: ${entry.status}`); +} +``` + +```python Python +import asyncio +from adcp.testing import test_agent + +async def main(): + result = await test_agent.simple.sync_governance( + accounts=[ + { + "account": {"account_id": "acct-social-001"}, + "governance_agents": [ + { + "url": "https://governance.pinnacle-media.com", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + } + ] + }, + { + "account": {"account_id": "acct-social-002"}, + "governance_agents": [ + { + "url": "https://governance.acme-buyer.com", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + } + } + ] + } + ] + ) + + if hasattr(result, 'errors') and result.errors: + raise Exception(f"Operation failed: {result.errors}") + + for entry in result.accounts: + print(f"{entry.account}: {entry.status}") + +asyncio.run(main()) +``` + + + +### Buyer-declared accounts (brand + operator) + + + +```json Request +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/account/sync-governance-request.json", + "idempotency_key": "e5b9f2c3-1234-48a0-1234-56789012345e", + "accounts": [ + { + "account": { + "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, + "operator": "pinnacle-media.com" + }, + "governance_agents": [ + { + "url": "https://governance.pinnacle-media.com", + "authentication": { + "schemes": ["Bearer"], + "credentials": "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + } + } + ] + } + ] +} +``` + + + +### Rotate governance agent credentials + +Call `sync_governance` again with updated `authentication`. Replace semantics means the new credentials overwrite the previous configuration. + +### Migrating from pre-3.1 multi-agent registration + +Earlier drafts of 3.0 allowed up to 10 governance agents per account with per-agent `categories`. 3.1 constrains `governance_agents` to exactly one entry and removes `categories`. Buyers that registered more than one agent against the previous shape MUST collapse to a single agent on their next `sync_governance` call; the seller's persisted state is replaced. The new request schema rejects more than one agent outright, so no "mixed-mode" window exists. + +**Buyer-side collapse decision.** Which of the previously-registered agents becomes the single agent is a buyer-internal decision — the protocol does not rank or recommend. Typical paths: (a) keep the agent with the broadest policy coverage (usually the budget/spend-authority agent) and fold specialist logic (legal, brand safety, regulatory review) into it as internal workflow; (b) deploy a new "front-door" governance agent that fans out to the previous specialists internally, and register only that agent; (c) keep the agent that was always the de facto governance surface and fold the others' specialist review into it as internal workflow without re-registering them. Surface the internal decomposition to auditors via `categories_evaluated` and `findings[].details` on check responses so the audit trail retains what each internal reviewer contributed. + +**Seller-side.** Sellers MAY, on first boot under the new schema, collapse previously-persisted multi-agent state to the first entry (ordered by original sync position) and log the migration to their audit trail. Sellers SHOULD surface a clear error to buyers whose next `sync_governance` call attempts to re-register multiple agents, pointing at this migration guidance. + +## Error Handling + +| Error Code | Description | Resolution | +|------------|-------------|------------| +| [`ACCOUNT_NOT_FOUND`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-not-found) | Referenced account does not exist or is not accessible | Verify account reference via [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) or [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/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 + +- [Sell-side governance boundaries](/dist/docs/3.3.0-beta.3/governance/sell-side-governance) — Understand governance-agent acceptance as a seller binding constraint, not shared campaign authority +- [list_accounts](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) — Discover accounts and their current governance agents +- [sync_accounts](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) — Provision or link advertiser accounts +- [check_governance](/dist/docs/3.3.0-beta.3/governance/campaign/tasks/check_governance) — How sellers call governance agents during media buy events +- [Accounts and agents](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents) — Account models, billing, and trust diff --git a/dist/docs/3.3.0-beta.3/ai-disclosure.mdx b/dist/docs/3.3.0-beta.3/ai-disclosure.mdx new file mode 100644 index 00000000000..ad66301a593 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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 AgenticAdvertising.org, with human editorial oversight: + +- **Addie** — AgenticAdvertising.org'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 AgenticAdvertising.org; 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. AgenticAdvertising.org 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 AgenticAdvertising.org'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 AgenticAdvertising.org 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 AgenticAdvertising.org 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 and targeting review SLA.** Because AgenticAdvertising.org is both the issuer and the AI grader of its certifications, every grading appeal and protocol-update targeting dispute gets a documented human-review path: + +- **Acknowledgement: 2 business days** from receipt at [certification@agenticadvertising.org](mailto:certification@agenticadvertising.org). +- **Resolution:** handled through the [certification complaints process](/dist/docs/3.3.0-beta.3/learning/policies/complaints), by an AgenticAdvertising.org staff reviewer who did not participate in the original grading or targeting decision. +- **Outcome on upheld appeals**: the credential is granted or the learner record is corrected, depending on the issue under review. +- **Escalation**: appeals that the staff reviewer cannot resolve are escalated to AgenticAdvertising.org program leadership. +- **Annual transparency report**: AgenticAdvertising.org 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 or protocol-update targeting dispute, email [certification@agenticadvertising.org](mailto:certification@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 the [Slack joining guide](/dist/docs/3.3.0-beta.3/community/joining-slack). 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 AgenticAdvertising.org publishes carries an embedded [C2PA](https://c2pa.org/) manifest signed by AgenticAdvertising.org: + +- **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). AgenticAdvertising.org 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 AgenticAdvertising.org 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 AgenticAdvertising.org — but it is a deliberate choice, not an oversight. + +If you find an AgenticAdvertising.org-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 AgenticAdvertising.org intersect with AgenticAdvertising.org's broader governance in ways readers should know: + +- AgenticAdvertising.org is both the issuer and the grader of its certifications (Addie evaluates coursework and grants credentials AgenticAdvertising.org sells). +- AgenticAdvertising.org's founder's other company (Scope3) contributed funding and foundational IP — see the [FAQ entry](/dist/docs/3.3.0-beta.3/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 AgenticAdvertising.org will be reflected on this page. diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/brand-json.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/brand-json.mdx new file mode 100644 index 00000000000..b633c9daf44 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/brand-json.mdx @@ -0,0 +1,1497 @@ +--- +title: brand.json Specification +description: "brand.json specification for AdCP. File format, five variants (portfolio, redirect, agent, authoritative location, brand canonical document), brand definition fields, mutual-assertion trust model, visual guidelines, colorways, type scale, trademarks, 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 five variants to accommodate different publishing models. + + +`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.3.0-beta.3/registry/index). + + +## Motivation + +Brand identity for a holdco could live in **one** brand.json owned by the parent — every child change requires editing the parent's file. If Converse wants to update its logo, someone edits Nike, Inc.'s file. If a holdco runs 100 subsidiary brands, all 100 teams converge on the same monolithic document. Brand teams own their identity, but a monolithic shape forces a single ops choke point at the corporate parent. It also blocks independent brands from publishing at all — a brand listed in someone else's portfolio has no protocol-level path to assert its own canonical data, even when domain control proves it could. + +The spec resolves this by letting a child brand publish its **own** canonical document while the house remains the authority for who is in the family. Inline `brands[]` stays a first-class option (parent owns the data); `brand_refs[]` adds pointer children whose canonical document lives elsewhere (child owns the data). A house mixes freely. The hierarchy is one level deep — only a house declares ownership; a brand cannot itself have children. + +## 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. + +For every domain-derived well-known fetch in the resolution algorithm, resolvers MUST follow HTTP redirects only between the originally requested hostname and its exact `www` counterpart, on HTTPS at the original port. For example, `example.com` may redirect to `www.example.com` and back; `brand-assets.example.com` MUST be refused even though it shares the same registrable domain. Resolvers MUST re-apply [SSRF controls](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#webhook-url-validation-ssrf) on every hop and stop after 3 redirects. Any other hostname requires an explicit [`authoritative_location`](#1-authoritative-location-redirect). This permits ordinary apex ↔ `www` hosting normalization without trusting other delegated or dangling sibling subdomains. Operators that use this convention remain responsible for keeping the exact `www` name under the same administrative control as the original hostname. + +## Variants + +The brand.json file supports five variants: + +| # | Variant | Use when | +|---|---|---| +| 1 | Authoritative Location Redirect | The canonical document is hosted at another URL | +| 2 | House Redirect | The brand domain rolls up to a larger house | +| 3 | Brand Agent | An MCP agent provides authoritative brand identity | +| 4 | House Portfolio | A house publishes its brands (inline, by pointer, or both) | +| 5 | Brand Canonical Document | A brand self-publishes its own identity (with optional `house_domain` pointer up) | + +Variants 1–3 are mutually exclusive with each other and with variants 4–5. Variants 4 and 5 compose: a House Portfolio (4) can reference Brand Canonical Documents (5) via `brand_refs[]`, and a Brand Canonical Document can point back at its house via `house_domain`. See [Mutual-assertion trust model](#mutual-assertion-trust-model) for how the two halves resolve. + +### 1. Authoritative Location Redirect + +Points to a hosted brand.json at another URL: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/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 + +Resolvers MUST fetch the exact HTTPS `authoritative_location` URL and MUST NOT follow an HTTP redirect from it. A service may publish a CDN URL directly, but that URL must return the document with a `200` response; any canonical-host or versioned-path redirect must be resolved before publication. + +Optional fields (shared with House Redirect — see [Redirect ergonomics](#redirect-ergonomics)): +- `redirect_reason`: structured signal for cache handling (`acquisition`, `rebrand`, `regional`, `legacy`, `consolidation`, `other`) +- `redirect_effective_at`: ISO timestamp when the redirect became effective +- `note`: free-text rationale + +### 2. House Redirect + +Points to the house domain that contains the full brand portfolio: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/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") +- `redirect_reason`: structured signal for cache handling (see [Redirect ergonomics](#redirect-ergonomics)) +- `redirect_effective_at`: ISO timestamp when the redirect became effective +- `note`: free-text rationale + +Use this when: +- Brand domain is owned by a larger house +- Regional/localized domains point to main house +- Legacy domains redirect to canonical + +### Redirect ergonomics + +Both redirect variants accept optional `redirect_reason` and `redirect_effective_at` to help consumers handle redirect transitions without sitting on stale cached state. + +`redirect_reason` is an enum: + +| Value | Meaning | Caching guidance | +|---|---|---| +| `acquisition` | The owning entity was acquired (e.g., one holdco buys another) | Shorten cache TTL until stable; many fields may move | +| `divestiture` | The owning entity spun out or sold off | Shorten cache TTL until stable; new ownership chain | +| `rebrand` | The brand or house renamed/repositioned | Shorten cache TTL until stable | +| `consolidation` | Sub-brands consolidated into the target | Shorten cache TTL until stable | +| `regional` | Regional/localized domain points to main house | Stable; standard caching | +| `legacy` | Old domain that redirects to canonical | Stable; standard caching | +| `other` | Reason not captured by the above | Free-text rationale belongs in `note` | + +`redirect_effective_at` (ISO 8601 timestamp) is the hard cache invariant: caches **MUST** treat any entry cached before this timestamp as stale and re-fetch through the redirect. The TTL-shortening guidance above is a SHOULD; this timestamp is a MUST and is the load-bearing fix for cache poisoning during M&A and other transitions. + +This is the brand.json analogue to the migration patterns in IAB Tech Lab's [ads.txt](https://iabtechlab.com/ads-txt/) (`OWNERDOMAIN`) and [sellers.json](https://iabtechlab.com/sellers-json/) (`seller_type`), which historically relied on out-of-band coordination — there was no machine-readable transition signal. `redirect_reason` + `redirect_effective_at` close that gap for brand.json. + +Example — acquisition redirect: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "house": "wpp.com", + "redirect_reason": "acquisition", + "redirect_effective_at": "2026-04-15T00:00:00Z", + "note": "Acquired by WPP April 2026" +} +``` + +### 3. Brand Agent + +Designates an MCP agent that provides brand information: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "agents": [ + { + "type": "brand", + "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 + +The house publishes its brands. A house may carry inline child definitions, pointer references to brands that self-publish, or both: + +- **`brands[]`** — inline child definitions. **Parent owns the data.** Best for sub-brands without their own domain (Nike SB, an internal product line) or sub-brands the holdco wants to manage centrally. +- **`brand_refs[]`** — pointer entries. **Child owns the data** at its own canonical document (variant 5). Best for sub-brands with their own domain that want self-publish authority (Converse, Jordan). + +A given child appears in **exactly one** of the two arrays. At least one of `brands[]` or `brand_refs[]` must be present. + +**Simple house (inline only):** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/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} + ] + } + ] +} +``` + +**Mixed hybrid (inline + pointer):** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "house": { + "domain": "nikeinc.com", + "name": "Nike, Inc.", + "architecture": "hybrid" + }, + "brands": [ + { + "id": "nike_sb", + "names": [{"en_US": "Nike SB"}], + "keller_type": "sub_brand" + } + ], + "brand_refs": [ + { "domain": "converse.com", "brand_id": "converse" }, + { "domain": "jordan.com", "brand_id": "jordan" } + ] +} +``` + +The `brand_refs[]` entry shape: + +| Field | Required | Meaning | +|---|---|---| +| `domain` | yes | Where the child's canonical brand.json lives. Must be unique within `brand_refs[]`. | +| `brand_id` | yes | Stable identifier for this brand within the house's portfolio. Must be unique within `brand_refs[]` and MUST NOT also appear in `brands[]`. | +| `managed_by` | no | Domain of the entity that operationally manages this brand (e.g., an agency network within a holdco). House-declared. **Consumers MUST NOT use it for trust or authorization decisions.** Aggregation across houses ("show me everything BBH manages") is the intended use — it's a directory field, not a trust field. | +| `effective_at` | no | ISO 8601 timestamp when the house established this ownership claim. Consumers age mutual-assertion edges from this date; omit and consumers age from their own first observation. | + +Trust semantics for the relationship between this entry and the child's `house_domain` claim are defined in [Mutual-assertion trust model](#mutual-assertion-trust-model) below. + +**House with delegation (WPP):** + +Holdcos delegate brand management to agency networks. WPP plc owns brands but Ogilvy or BBH actually runs them day-to-day. `managed_by` on a `brand_refs[]` entry captures this as a unilateral declaration by the owning house. The leaf brand doesn't reference the manager. UIs render BBH Sport under BBH for agency views; trust validation walks BBH Sport → WPP only. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "house": { + "domain": "wpp.com", + "name": "WPP plc" + }, + "brand_refs": [ + { "domain": "bbh-sport.com", "brand_id": "bbh_sport", "managed_by": "bbh.com" }, + { "domain": "ogilvy-toyota.com", "brand_id": "ogilvy_toyota", "managed_by": "ogilvy.com" }, + { "domain": "wpp-direct.com", "brand_id": "wpp_direct" } + ] +} +``` + +`managed_by` is a **directory field, not a trust field.** It exists so consumers can group brands by who actually runs them day-to-day — the canonical view a buyer-side DSP wants when shopping inventory across an agency network. Consumers MUST NOT use it for trust or authorization decisions; that line still flows through mutual assertion between leaf and house. See [Conformance](#conformance). + +### 5. Brand Canonical Document + +Self-published per-brand document where the brand owns its own identity attributes. Hosted at the brand's own `/.well-known/brand.json` (or via [authoritative location redirect](#1-authoritative-location-redirect)). A brand may declare its house via `house_domain` or stand alone (no parent house — Patagonia, Liquid Death — omit the field). Trust semantics for the `house_domain` relationship are defined in [Mutual-assertion trust model](#mutual-assertion-trust-model) below. + +**With a house — Converse under Nike:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "id": "converse", + "names": [{"en_US": "Converse"}], + "keller_type": "sub_brand", + "house_domain": "nikeinc.com", + "logos": [ + {"url": "https://converse.com/logo.svg", "variant": "primary"} + ], + "tagline": "Sneaker for the streets" +} +``` + +**Standalone — Patagonia:** + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "id": "patagonia", + "names": [{"en_US": "Patagonia"}], + "keller_type": "independent", + "logos": [ + {"url": "https://patagonia.com/logo.svg", "variant": "primary"} + ], + "tagline": "We're in business to save our home planet." +} +``` + +Use this when a brand has its own domain and wants self-publish authority for its identity (logos, colors, tone, taglines). The brand carries the same identity fields as an inline entry in `brands[]`. + +**Top-level fields specific to this variant:** + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | string | Yes | Brand identifier (lowercase alphanumeric with underscores) | +| `names` | array | Yes | Localized names | +| `house_domain` | string (domain) | No | Pointer to the corporate house this brand belongs to. The brand is treated as standalone if omitted. Single-hop — a brand cannot itself declare `brand_refs[]`. | +| `$schema`, `version`, `last_updated` | string | No | Document metadata | + +**All other brand-identity fields apply** (`logos`, `colors`, `fonts`, `tone`, `tagline`, `visual_guidelines`, `keller_type`, `parent_brand`, `properties[]`, `industries[]`, `target_audience`, `description`, `agents[]`, `contact`, `trademarks[]`, `data_subject_contestation`, etc. — see [Brand definition](#brand-definition) for the full field list). The document MUST NOT carry house-only fields (`house`, `brands`, `brand_refs`, `authorized_operators`) or redirect fields (`authoritative_location`, `house` as string, `region`, `note`, `redirect_reason`, `redirect_effective_at`). + +## Mutual-assertion trust model + +Trust resolves at two layers — **brand identity** (logos, colors, tone, tagline, etc.) and **brand relationships** (who owns this brand, who can speak for it). The two layers separate cleanly. Identity is verifiable from a single TLS-served document; relationships require both sides to reciprocate. + +| Edge | Identity trust | Relationship trust | Notes | +|---|---|---|---| +| Inline child in `brands[]` | Trusted (parent's TLS covers the data) | Trusted (the house authored the entry) | The classic shape. Parent owns the data. | +| **Mutual assertion**: leaf says `house_domain: A`, A's `brand_refs[]` includes leaf | Trusted (leaf's TLS) | Trusted (both sides reciprocate) | **Full trust.** Governance propagation, member-feature inheritance, billable inclusion all flow. | +| **Leaf-only**: leaf says `house_domain: A`, A's `brand_refs[]` does not include leaf | Trusted (leaf's TLS) | Unverified | Identity is real — the brand controls its own domain. The parent claim is unverified; governance and billable inclusion are blocked pending reciprocation. See [Self-healing through notification](#self-healing-through-notification). | +| **House-only**: A's `brand_refs[]` includes leaf, leaf has no `house_domain` | Trusted (leaf's TLS — as a standalone document) | Unverified in the other direction | The leaf hasn't claimed A. Treat the leaf as standalone; A's claim is supportive metadata. | +| Standalone (no `house_domain`) | Trusted (leaf's TLS) | n/a — no claimed relationship | Patagonia, Liquid Death. The brand is its own thing. | + +**Key asymmetry.** A leaf with verified TLS at its own domain is authoritative for *its own identity attributes* whether or not its parent claim is reciprocated. The relationship layer is what mutual assertion gates. A naïve "claimed, unverified ⇒ ignore the leaf entirely" reading is wrong — the leaf's logos/colors/tone are still real. + +**Standalone trumps third-party claim.** A Brand Canonical Document with no `house_domain` is standalone. If some other house's `brand_refs[]` lists it anyway, the leaf's silence is dispositive: treat as standalone; the third-party claim is unverified metadata only. See [Conformance](#conformance) for the normative statement. + +Validation is a runtime check (the crawler fetches both documents and compares); the JSON Schema accepts the fields independently. + +### Self-healing through notification + +The leaf-only edge case is common in practice — a sub-brand team stands up its own canonical document before the parent's portfolio team has time to add the reciprocal entry. Today this leaves the brand stuck: identity is good, but media buys and governance decisions are blocked. + +The spec defines a self-healing loop. For houses **without** a brand-agent: + +1. The house publishes a reachable `contact.email` at the top level of its `brand.json`. +2. When a consumer encounters a leaf-only edge — leaf claims `house_domain: A`, A's `brand_refs[]` doesn't include leaf — the consumer **SHOULD** notify A's `contact.email` that a leaf is claiming reciprocation and is blocked pending verification. +3. The house team adds the entry to `brand_refs[]`. On the next crawl/re-validation, the edge upgrades to mutual. + +For houses **with** a brand-agent advertising [`verify_brand_claim`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim), the consumer asks the agent directly instead — see [Agent-augmented verification](#agent-augmented-verification) below. The agent path surfaces richer states (`pending_review`, `transferring`, `disputed`, `licensed_in`) than the email loop can express, and when both house and leaf publish agents, mutual assertion completes via two signed agent calls without a static-file crawl. + +### Agent-augmented verification + +When the brand publishes a brand-agent ([variant 3](#3-brand-agent) or a `type: "brand"` entry in `agents[]` on variant 4/5), the agent can be asked verification questions via [`verify_brand_claim`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) — a single tool that takes a `claim_type` (`subsidiary` / `parent` / `property` / `trademark`) and returns the brand's authoritative answer. The brand-agent is brand.json hidden behind a server: same data the static file carries, plus the richer states the static file can't capture (`pending_review`, `transferring`, `disputed`, `licensed_in`, `licensed_out`). + +**The trust model is asymmetric by direction.** Without this asymmetry, a malicious or mistaken house could claim subsidiaries it doesn't own and have consumers extend trust on the strength of the signature alone. + +The rules below apply only after the [brand-authorization cross-check](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) returns verifier-local `trust: "trusted"`. A skipped or failed check is `untrusted` for relationship decisions and cannot extend or revoke trust. + +| Direction | Trust rule | +|---|---| +| **Rejection** — trusted agent returns `disputed` or `not_ours` | Authoritative unilaterally. A trusted brand has standing to refuse association regardless of what any other party publishes. An untrusted rejection cannot override a reciprocating claim. | +| **Assertion** — agent signs `owned` / `pending_review` / `transferring` / `licensed_*` | Informative but NOT trust-extending alone. The reciprocating side must still confirm (via its own brand-agent's `parent` claim type, or via static crawl) before relationship trust extends. | + +When both house and leaf publish brand-agents, mutual assertion can complete at the agent layer: partner calls `verify_brand_claim` with `claim_type: "subsidiary"` on the house AND with `claim_type: "parent"` on the leaf. Both `owned` → mutual assertion, real-time, signed by both parties, no crawl required. This is the cleanest path for trust extension. + +**Mutual assertion proves consistency between two parties, not standing.** Two reciprocally-asserting agents at attacker-controlled domains can sign matching `owned` responses; mutual assertion confirms only that they agree, not that either has legitimate authority over the underlying brand. The final trust gate is still domain control + TLS — verified on the consumer side, against the legal/operational entity the consumer expects to own the domain. Mutual assertion at the agent layer is the floor; consumer-side standing checks (domain registration, real-world identity) are still the caller's responsibility for high-trust decisions. + +Agent responses are signed under the brand's `adcp_use: "response-signing"` JWK. See the [task reference](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) for the full request/response shapes and per-claim-type details. + +### Field resolution + +There is no inheritance/override block. Each consumer-side question has a single answer: + +| Question | Resolution | +|---|---| +| Brand identity (logos, colors, tone, tagline, voice) | Read from brand's own canonical document. For inline children, read from the parent's `brands[]` entry. | +| Brand contact, properties, industries | Read from brand's own canonical document. | +| Trademarks, authorized_operators, corporate contact | Read from the house's brand.json. | +| `data_subject_contestation`, compliance policies, regulated-category flags | **Strictest-of** house-level and brand-level. See below. | +| Mutual-assertion verification | Fetch both brand and house, compare `house_domain` ↔ `brand_refs[]`. | + +**Identity fields** (`name`, `names`, `logos`, `colors`, `fonts`, `tone`, `voice`, `prohibited_terms`, `tagline`, `visual_guidelines`, `avatar`): brand-level value is authoritative; the house value is not consulted. Brand teams own brand identity. + +### Authorized Operator Resolution + +`authorized_operators[]` entries may be scoped by brand, country, activity, and time. Consumers evaluating an operator relationship MUST ignore an entry when the current time is before `valid_from` or at/after `valid_until`. Omitted validity fields mean the house has not published a machine-readable start or end bound. + +When `scopes` is omitted, consumers should treat the entry as backwards-compatible broad authorization for the listed brands and countries. When `scopes` is present, the operator is authorized only for the listed activities, or for all activities when it explicitly contains `all`. + +`authorized_operators[]` does not replace publisher-side inventory authorization. For direct, delegated, or network inventory, buyers still need the publisher's matching `adagents.json` authorization; the brand file establishes who may represent the brand or house, while `adagents.json` establishes who may sell a publisher's inventory. + +**Compliance and governance fields**: the resolved value is the **strictest of** the house-level and brand-level values. A brand cannot weaken a house's governance assertions; it can only add stricter constraints. Applies to: + +- `data_subject_contestation` — both contacts SHOULD be presented to data subjects (consumers may use either; brand-level does NOT replace house-level) +- `compliance_policies`, audience exclusions, regulated-category flags — resolved as union (more restrictions wins) + +This is the load-bearing reason holdcos publish corporate-level governance: brand teams should not be able to soften compliance by self-publishing. Identity is brand-wins because that's what brand teams own; governance is strictest-of because that's how legal/compliance regimes actually work. The schema does not encode this rule; it's a resolution-layer semantic. + +See [Resolution algorithm](#resolution-algorithm) below for the full crawler procedure. + +### Acquisitions and reorganizations + +Existing redirect variants handle M&A natively: + +1. Pre-deal: `dentsu.com/.well-known/brand.json` is a House Portfolio. Dentsu brands' canonical docs say `house_domain: "dentsu.com"`. +2. Deal closes: Dentsu's `brand.json` is replaced with a [House Redirect](#2-house-redirect) → `{ "house": "wpp.com" }`. WPP adds the acquired brands to `brand_refs[]` with `managed_by: "dentsu.com"` for ops continuity and `effective_at` set to the deal-close date. +3. Post-deal: leaves still pointing at `house_domain: "dentsu.com"` resolve through the redirect to WPP. Mutual-assertion verification follows the redirect chain (see [Conformance](#conformance)) and holds. No urgent leaf migration needed. + +### Adopting brand_refs[] for an existing portfolio + +An existing House Portfolio publisher (today on inline `brands[]`) doesn't need to migrate. `brand_refs[]` is additive and pull-based — a brand moves out of `brands[]` only when its team decides to self-publish. + +The migration path for a single brand: + +1. The child brand stands up `/.well-known/brand.json` at its own domain as a Brand Canonical Document, declaring `house_domain: ""`. +2. The house team removes the child's entry from `brands[]` and adds `{ domain, brand_id, effective_at }` to `brand_refs[]`. `brand_id` MUST match the value the child uses; otherwise the cross-array uniqueness invariant doesn't help future readers. +3. The crawler picks up the mutual assertion on next refresh. + +Between steps 1 and 2, the leaf-only edge is unverified at the relationship layer but the leaf's *identity* is still trusted on TLS (see [Self-healing through notification](#self-healing-through-notification)). Consumers SHOULD email the house's `contact.email` to prompt step 2. + +The AAO registry consumes both shapes today and will not re-classify a brand that moves from `brands[]` to `brand_refs[]` — the `brand_id` is the stable identifier across the migration. + +### Out of scope + +The single-house, single-hop trust model deliberately doesn't cover four shapes: + +- **Joint ventures with two parents** (Hulu pre-Disney, JVs in pharma, automotive partnerships). A leaf has at most one `house_domain`. JV structures must pick one canonical parent or use a holding entity; the protocol won't model two-parent ownership. +- **PE rollups and white-label arrangements that want opacity.** Mutual assertion publishes ownership at well-known URLs. A holdco that wants to operate brands without public disclosure can use the existing monolithic `brands[]` shape (which doesn't expose the parent to leaf-domain crawlers) but cannot use mutual-assertion. Mutual assertion trades opacity for verifiability — that's the design. +- **Jurisdictional governance divergence** (e.g., a Marriott franchisee in Germany whose data-subject contestation rules differ from the US parent's). Strictest-of resolution means brand-level publishers can only *add* constraints. A brand in a less-regulated jurisdiction cannot drop a house-level rule that doesn't apply to it. Workaround: the house publishes the strictest applicable per-region rule. +- **Standing licensed relationships as a static brand.json surface.** [`verify_brand_claim`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) with `claim_type: "trademark"` can return `licensed_in` / `licensed_out` because the underlying relationships are real — Marriott franchisees use the MARRIOTT mark under license, music catalogs are licensed-out per territory, jurisdictional licensing splits ("licensed in CN, self-managed elsewhere") are common. The brand-agent can speak to these from internal records. **But `brand.json` itself doesn't yet have a publishing surface for standing licensed relationships** parallel to `brand_refs[]` for ownership. The existing rights protocol (`rights_agent`, `acquire_rights`) handles *transactional* licensing — negotiate a deal — not standing declarations. This gap is real and tracked as a follow-up; the verify surface exposes the states because partners need to consume them, but the static-file substrate that backs them is a separate design alongside the rights-protocol team. + +These cases belong in governance / corporate-structure specs, not brand.json. `brand.json` is the brand-identity surface; corporate legal structure and licensing-relationship publishing are their own concerns. + +## 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 + +### Document language and localized fields + +House Portfolio and Brand Canonical documents may set top-level `default_language` to a BCP 47 language tag. It identifies the language of legacy plain-string and plain-array content in that document. When omitted, readers MUST behave as though it were `"en"`. + +Fields that support localization retain their legacy plain value and also accept an ordered array of one-locale maps. Scalar fields map a locale to one string; `tone.attributes`, `tone.dos`, and `tone.donts` map a locale to the complete ordered string list: + +```json +{ + "default_language": "en-GB", + "tone": { + "voice": [ + {"en-GB": "Warm and direct"}, + {"fr": "Chaleureux et direct"} + ], + "attributes": [ + {"en-GB": ["plain-spoken", "optimistic"]}, + {"fr": ["accessible", "optimiste"]} + ] + }, + "assets": [{ + "asset_id": "hero_summer", + "asset_type": "image", + "url": "https://assets.example/hero.jpg", + "name": [{"en": "Summer hero"}, {"fr": "Visuel principal été"}] + }] +} +``` + +Readers MUST resolve a localized field in this order: + +1. exact requested BCP 47 tag; +2. requested base language; +3. exact `default_language` tag; +4. `default_language` base language; +5. the legacy plain string or complete plain array, when present; +6. otherwise the field is unavailable. + +Readers MUST NOT select the first or any other arbitrary translation when none of those candidates exists. Before matching legacy underscore keys such as `fr_CA`, readers replace `_` with `-` and apply the AdCP locale-tag canonicalization profile. + +For the localized scalar and complete-list fields introduced in 3.2, producers MUST NOT repeat a locale after canonicalization. For example, one localized value cannot contain both `en-US` and `en_US`. This restriction does not apply to the legacy `names` and `tagline` alias arrays, which continue to allow multiple values for one language. + +Localized tone arrays are selected as a whole. Readers MUST NOT localize elements independently or combine entries from different languages; list ordering and language consistency are part of the published value. This 3.2 slice covers `tone.voice`, `tone.attributes`, `tone.dos`, `tone.donts`, and asset `name` and `description`. Logo and product-catalog fields remain unchanged. + +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 | +| `agents` | array | No | Agents acting for this brand, each `{ type, url, id }` plus optional `jwks_uri`, `description`, `countries`. Agent keys here serve the RFC 9421 signing profile, deprecated in 3.3; agents on the Web Bot Auth profile publish keys at their own origin and are listed in [trust.json](/dist/docs/3.3.0-beta.3/reference/trust-json). Rights agents (`type: "rights"`) add `available_uses` and `right_types`. | +| `brand_agent` | object | No | Deprecated: use an `agents[]` entry with `type: "brand"`. | +| `rights_agent` | object | No | Deprecated: use an `agents[]` entry with `type: "rights"`. | +| `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` is a localized scalar; `attributes`, `dos`, and `donts` localize as complete arrays. | +| `prohibited_terms` | array of strings | No | Words or phrases the brand never uses in copy. See [Prohibited terms](#prohibited-terms). | +| `tagline` | string | No | Brand tagline or slogan | +| `visual_guidelines` | object | No | Structured visual rules for generative creative systems | +| `trademarks` | array | No | Registered trademarks owned or licensed by this brand (`registry`, `number`, `mark`, optional `status`, `license_type`, `countries`). See [Trademarks](#trademarks). | + +### 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). + + + The examples above use bare language keys, while existing `brand.json` + documents may also use legacy underscore-separated keys such as `en_US`. + Those keys are not values of `/schemas/core/locale-tag.json`. When bridging + brand content into a newer language-tag field, replace `_` with `-`, apply + the AdCP canonical wire profile, and validate the result; do not copy the + legacy key unchanged. The underscore form remains accepted here for + backwards compatibility pending a separately versioned migration. + + +### 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 | + +### Prohibited terms + +`prohibited_terms` is an optional array of plain strings on the brand: words or phrases the brand never uses in copy, such as `"binge"` or a competitor's name. It sits beside `tone`, not under it, because `tone` also accepts a plain string. + +```json +{ + "tone": { "voice": "bold and direct" }, + "prohibited_terms": ["binge", "cheap"] +} +``` + +- **Advisory.** Matching is the receiver's choice; case-insensitive whole-term matching is the expected default. Receivers that cannot enforce the list can still pass it to generation as guidance. Terms apply regardless of copy language; per-locale lists are not defined. +- **Union with slot lists.** For a text slot, the effective list is the union of the brand's `prohibited_terms` and the format slot's `text-asset-requirements.prohibited_terms`. Neither list weakens the other. +- **Brand-level only.** The field has no house-level equivalent and is not inherited; a brand's value is authoritative. It is not yet returned by [`get_brand_identity`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity), whose `fields` list is closed; read it from `brand.json`. +- **No per-call override.** `brand_kit_override` does not carry `prohibited_terms`. Publish a different `brand.json` and reference it by `domain` to change the list. +- **Not `prohibited_claims`.** The creative brief's `compliance.prohibited_claims` holds claims, which are statements judged by meaning ("clinically proven"); this list is literal words and phrases. + +### 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` | + +### Logo selection slots + +Use `logos[].slots[]` when a renderer needs to choose the right logo variant for a specific surface, such as a light logo card, dark logo card, profile mark, CTV end card, co-brand lockup, or marketplace listing. `logos[].id` gives downstream rules a stable target that does not depend on a mutable asset URL. + +```json +{ + "logos": [ + { + "id": "primary_horizontal", + "url": "https://assets.example.com/logos/primary.svg", + "orientation": "horizontal", + "background": "light-bg", + "variant": "primary", + "slots": ["logo_card_light", "nav_header", "marketplace_listing"] + }, + { + "id": "knockout_horizontal", + "url": "https://assets.example.com/logos/knockout.svg", + "orientation": "horizontal", + "background": "dark-bg", + "variant": "secondary", + "slots": ["logo_card_dark", "ad_end_card"] + } + ] +} +``` + +Canonical creative formats still use `asset_group_id: "logo"` for the manifest asset group. If a product narrows that slot with `format_options[].params.slots[].logo_slots[]`, builders should select from `brand.json` `logos[]` where `logos[].slots[]` intersects the requested slots. If the format also declares `required_logo_slots[]`, missing coverage should surface as a validation warning or approval mapping instead of falling back silently to prose. + +`visual_guidelines.logo_usage_rules[]` can then apply placement constraints to a stable `logo_id`: + +```json +{ + "visual_guidelines": { + "logo_usage_rules": [ + { + "logo_id": "primary_horizontal", + "slots": ["logo_card_light", "marketplace_listing"], + "minimum_size": { "height": "18px" }, + "clear_space": "1x cap height", + "forbidden_contexts": ["photography_without_knockout"], + "severity": "must" + } + ] + } +} +``` + +### Color constraints + +Use `visual_guidelines.color_constraints[]` for machine-readable color usage and pairing rules, such as accent-only colors, forbidden background combinations, or colors that should never appear together. + +```json +{ + "visual_guidelines": { + "color_constraints": [ + { + "color": { "kind": "name", "name": "market_yellow" }, + "applies_to": ["accent"], + "forbidden_on": [ + { "kind": "surface", "surface": "background" }, + { "kind": "surface", "surface": "text" } + ], + "never_pair_with": [{ "kind": "name", "name": "linen" }], + "contexts": ["digital", "print", "ctv_end_card"], + "severity": "must", + "description": "Market yellow is accent only." + } + ] + } +} +``` + +Each `color_ref` uses an explicit `kind` discriminator: `kind: "name"` looks up a key in `colors{}`, `kind: "value"` carries a literal hex color, and `kind: "surface"` identifies a layout surface such as `background`, `text`, or `logo_background`. + +### Mark lockups + +Use `visual_guidelines.mark_lockups[]` for co-brand, partner, sponsor, program, or secondary-mark layout rules. These fields make ordering, spacing, separators, and size-ratio guidance queryable while leaving optical balancing to layout/render review. + +```json +{ + "visual_guidelines": { + "mark_lockups": [ + { + "lockup_type": "co_brand", + "ordering": "brand_first", + "brand_logo_id": "primary_horizontal", + "contexts": ["partner_campaign", "sponsored_content"], + "separator": { + "type": "keyline", + "color": { "kind": "name", "name": "text" }, + "width": "1px" + }, + "min_gap": "1x clear space", + "brand_min_optical_weight_ratio": 1, + "partner_max_optical_weight_ratio": 1, + "severity": "must" + } + ] + } +} +``` + +### Tooling notes: ingesting brand books + +Tools that create a draft `brand.json` from a brand-guide PDF should keep asset extraction and guideline interpretation separate. + +Use deterministic extraction to produce candidate asset IDs: + +- embedded PDF images for photography, mockups, raster icons, and examples +- rendered-page crops for vector logos, marks, color swatches, and logo specimens that are not embedded image files + +Then use a multimodal model to classify those candidate IDs into proposed `logos[]`, `assets[]`, `colors`, and `visual_guidelines` rules. The model should map back to known candidate IDs; it should not be treated as the source of original asset bytes. + +A draft ingestion record can point from a proposed logo entry back to evidence: + +```json +{ + "asset_id": "candidate_logo_0007", + "source_page": 15, + "extraction_method": "rendered_page_crop", + "proposed_logo": { + "id": "primary_wordmark_dark_card", + "variant": "wordmark", + "background": "dark-bg", + "slots": ["logo_card_dark", "marketplace_listing"] + }, + "review_status": "needs_review" +} +``` + +Do not publish those candidates directly as canonical `logos[]` until a reviewer confirms the crop, assigns the correct slots/backgrounds, and replaces the candidate file with a durable HTTPS logo asset URL. If the source PDF lacks hosted assets, operator authorization, or trademark evidence, that is an ingestion warning rather than a schema gap. + +### Public hosted asset promotion + +AAO-hosted brand assets are for cases where a reviewer or verified owner has approved a private upload for public `brand.json` use. Extraction alone never publishes bytes. The private analysis artifact remains separate from the public asset row, and `brand.json` only receives the public URL after approval. + +Recommended flow: + +1. Store the uploaded or extracted file privately for analysis. +2. Present the candidate to the operator with the exact proposed `brand.json` diff. +3. Require explicit approval before promotion. +4. Promote the approved bytes to a stable public HTTPS URL. +5. Write that public URL into `logos[]`. + +AAO-hosted URLs use this shape: + +```text +https://agenticadvertising.org/assets/brands/{domain}/{assetId}.{ext} +``` + +For example: + +```json +{ + "logos": [ + { + "id": "primary_wordmark", + "url": "https://agenticadvertising.org/assets/brands/acme.example/{assetId}.png", + "variant": "wordmark", + "slots": ["logo_card_light"], + "width": 512, + "height": 128 + } + ] +} +``` + +Public logo promotion must preserve provenance metadata outside the published `brand.json`: original filename, uploader user and organization when known, source flow, created time, content type, dimensions, hash, and whether the path was owner-attested, delegated, or community-submitted. Moderation and owner approval state are operational metadata; consumers should not need to parse them from the public URL. + +Initial promotion limits are intentionally conservative: accept only image MIME types needed for logos (`image/png`, `image/jpeg`, `image/webp`, `image/gif`, and sanitized `image/svg+xml`), cap uploads at 5 MB, detect raster dimensions before publish, and either sanitize SVG aggressively or reject it until the sanitizer is enabled for the surface. Private signed URLs, file-viewer links, and analysis-only object paths must not appear in published `brand.json`. + +Deletion should tombstone the public asset row instead of reusing the URL for unrelated bytes. Replacement should create a new asset URL and update `brand.json` to point at it. Previously published URLs may continue to serve the old approved bytes for cache stability, redirect to the replacement when there is a one-to-one successor, or return `404` after a legal/security takedown; they must never silently serve a different asset under the same ID. + +### 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" + ] +} +``` + +## Trademarks + +Registered trademarks may appear at the **house level** (corporate marks — e.g., NIKE owned by Nike, Inc.) or at the **brand level** (brand-specific marks — e.g., CONVERSE owned by Converse). Both arrays are valid claims; resolution between them is **union**. + + +**Licensed-in / licensed-out relationships** (Marriott franchisees using the MARRIOTT mark, music catalogs licensed per territory, etc.) are queryable via the brand-agent today through [`verify_brand_claim`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) with `claim_type: "trademark"`. A static `brand.json` publishing surface for standing licensed relationships (parallel to `brand_refs[]` for ownership) is a future RFC alongside the rights-protocol team. The static `trademarks[]` array below covers ownership only; licensing posture comes from the agent. + + + +```json +{ + "trademarks": [ + { + "registry": "USPTO", + "number": "12345678", + "mark": "CONVERSE", + "status": "active", + "license_type": "owned", + "countries": ["US"] + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `registry` | string | Yes | Trademark registry (e.g., `USPTO`, `EUIPO`, `JPO`, `CNIPA`) | +| `number` | string | Yes | Registration number as issued by the registry | +| `mark` | string | Yes | The registered mark as published | +| `status` | enum | No | `active`, `pending`, `abandoned`, `cancelled`, `expired`. Omit for active marks if status tracking is not maintained. | +| `license_type` | enum | No | `owned` (default), `licensed_in`, `licensed_out` | +| `countries` | array | No | ISO 3166-1 alpha-2 country codes where this registration applies. Omit for global or where the registry's jurisdiction is implicit. | + +Holdcos with cross-jurisdiction conflicts (USPTO `CONVERSE` vs EUIPO `CONVERSE` under different owners) should publish each registration as a separate entry and use `countries` to scope where the mark applies. + +## 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`. Direct, delegated, and network values match `delegation_type` in adagents.json for bilateral verification; `owned` is a brand.json-only ownership claim. See [ad networks](/dist/docs/3.3.0-beta.3/sponsored-intelligence/networks). | + +### Property types + +Matches AdCP property-type enum: + +- `website` +- `mobile_app` +- `ctv_app` +- `desktop_app` +- `dooh` +- `podcast` +- `radio` +- `linear_tv` +- `streaming_audio` +- `ai_assistant` + +### 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. For direct, delegated, or network paths, the publisher confirms by setting the matching `delegation_type` on the agent's authorization in their [adagents.json](/dist/docs/3.3.0-beta.3/governance/property/adagents). For first-party inventory, `relationship: "owned"` is the operator's inline ownership declaration. Sell-side implementations need the operator's `brand.json` claim and, when inventory is publisher-authorized or delegated, the publisher's matching `adagents.json` authorization. See [seller setup](/dist/docs/3.3.0-beta.3/brand-protocol/seller-setup) for the step-by-step sell-side pattern and [ad networks](/dist/docs/3.3.0-beta.3/sponsored-intelligence/networks) for network-specific guidance. + +## Resolution algorithm + +To resolve a domain to a canonical brand: + +1. Fetch `https://{domain}/.well-known/brand.json`. +2. Check variant: + - **authoritative_location**: fetch that exact HTTPS URL with zero HTTP redirects, then continue from step 2. + - **house** (string): fetch from house domain, continue from step 2. + - **Brand Agent** (no top-level `id`, `names`, or `house`; carries `agents[]` with a `type: "brand"` entry, or the deprecated `brand_agent`): return the agent URL — the agent is authoritative. + - **House Portfolio** (`house` object + `brands[]` and/or `brand_refs[]`): for an inline child, find the brand whose `properties[]` or `id` matches the query; for a pointer child, follow `brand_refs[].domain` and resolve once — the followed document MUST be a Brand Canonical Document, never another House Portfolio. + - **Brand Canonical Document** (`id` + `names` at top level): the document is the brand. If `house_domain` is present, fetch the house's `brand.json` to verify reciprocation in its `brand_refs[]` (mutual assertion). When the house side is itself a [House Redirect](#2-house-redirect), follow the redirect chain on the house side before comparing. Read corporate-level fields (e.g., `data_subject_contestation`) from the house if not present on the brand; for compliance fields, resolve **strictest-of** house and brand (see [Mutual-assertion trust model](#mutual-assertion-trust-model)). +3. Return canonical brand information. + +Maximum document redirect depth: 3 hops. This budget counts `authoritative_location` and House Redirect variants; it is separate from the initial well-known HTTP redirect limit in [File location](#file-location). Each explicit `authoritative_location` fetch has an HTTP redirect limit of zero. The brand → house lookup is single-hop (no recursive parent walks). See [Mutual-assertion trust model](#mutual-assertion-trust-model) for trust semantics. + +**Document size.** A brand.json SHOULD stay under 2 MiB (2,097,152 bytes). Registry and identity consumers, and verifiers that fetch brand.json as a [signing trust root](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url), apply the same 2 MiB body cap, enforced while the body streams, so a larger file is rejected. Consumers still enforcing the 256 KiB cap from earlier releases reject large files; see the [3.2 to 3.3 migration guide](/dist/docs/3.3.0-beta.3/reference/migration/3-2-to-3-3#brand-json-size-cap-rises-to-2-mib). + +## Complete examples + +### Small Business + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/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.3.0-beta.3/brand.json", + "version": "1.0", + "agents": [ + { + "type": "brand", + "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.3.0-beta.3/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.3.0-beta.3/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" + } + ], + "agents": [ + { + "type": "rights", + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment", + "jwks_uri": "https://rights.lotientertainment.com/.well-known/jwks.json", + "available_uses": ["likeness", "voice", "endorsement"], + "right_types": ["talent"], + "countries": ["NL", "BE", "DE"] + } + ] + } + ] +} +``` + +The `agents[]` entry does both jobs: `available_uses`, `right_types`, and `countries` tell crawlers what's licensable without an MCP call, and `url` plus `jwks_uri` authorize the exact rights endpoint for credential verification. 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.3.0-beta.3/brand.json", + "house": "nikeinc.com", + "region": "CN" +} +``` + +## Caching + +Recommended cache TTLs: +- Canonical files: 24 hours +- Redirect files: 24 hours +- Failed lookups: 1 hour + +## Conformance + +These invariants MUST be enforced by validators and crawlers; JSON Schema cannot express them directly. + +**Portfolio invariants** + +- **`brand_id` cross-array uniqueness.** A given `brand_id` MUST NOT appear in both `brands[]` and `brand_refs[]` of the same house. Publisher must choose one. +- **`brand_id` within-array uniqueness.** A given `brand_id` MUST be unique within `brands[]` and unique within `brand_refs[]` of the same house. +- **`domain` within-array uniqueness.** Each `brand_refs[].domain` MUST be unique within the array. Two entries pointing at the same domain with different `brand_id` values is undefined — there can be only one canonical pointer per domain per house. +- **`house_domain` placement.** `house_domain` MUST NOT appear on entries inside `brands[]`. It is a Brand Canonical Document top-level field; an inline child cannot carry a parent pointer of its own. + +**Trust invariants** + +- **Mutual-assertion is the relationship-trust edge.** Consumers MUST NOT extend relationship trust (auto-provisioning, member-feature inheritance, billable seat inclusion) through one-sided claims. Mutual assertion (child's `house_domain` matches a `brand_refs[]` entry on the named house) is the canonical trust edge. +- **Identity is TLS-only.** A leaf brand's own identity attributes (logos, colors, tone, tagline, visual_guidelines) are authoritative based on the leaf's TLS-served document alone, regardless of mutual-assertion state. A leaf-only relationship claim does not invalidate the leaf's identity. +- **House Redirects on the house side MUST be followed.** When verifying mutual assertion, if the named house's `brand.json` is a House Redirect, the consumer MUST follow the redirect chain (up to the 3-hop limit) before comparing `brand_refs[]` membership. Otherwise post-acquisition leaves silently lose trust. +- **Standalone trumps third-party claim.** A Brand Canonical Document with no `house_domain` is standalone, regardless of any third-party house's `brand_refs[]` claim about it. The leaf's silence is dispositive. (Stated once here; descriptive prose elsewhere defers to this clause.) +- **`managed_by` is a directory field, not a trust field.** Consumers MUST NOT use `managed_by` for trust or authorization decisions. Aggregation by `managed_by` ("show me everything BBH manages") is the intended use — it's an operational directory across houses, not a trust assertion. + +**Resolution invariants** + +- **Compliance fields strictest-of.** For governance fields — including `data_subject_contestation`, `compliance_policies`, `policy_categories`, audience exclusions, regulated-category flags, and brand-level `disclaimers[]` that the house also publishes — the resolved value is the union/strictest of house-level and brand-level. Brand-level publishers MUST NOT rely on weakening house-level assertions. This is distinct from relationship trust — strictest-of is a resolution rule, not a trust gate. +- **Edge aging.** Mutual-assertion edges SHOULD be aged on two independent clocks. Consumers SHOULD treat an edge as one-sided when the publisher-declared `brand_refs[].effective_at` (or, if absent, the consumer's durable first observation) exceeds the consumer's declaration-age ceiling, even if re-validation continues to succeed. A future `effective_at` is not active yet and MUST NOT extend trust before that time. Consumers SHOULD separately bound how long a previously confirmed edge may remain trusted while re-validation is unavailable. AgenticAdvertising.org's reference resolver uses a 180-day declaration-age ceiling and a 24-hour confirmation-retention window; consumers MAY choose stricter values. + +**Self-healing** + +- **Leaf-only edges SHOULD trigger notification.** When a consumer encounters a leaf claiming `house_domain: X` with no reciprocal entry on X, the consumer SHOULD notify X's `contact.email` so the house team can complete the reciprocation. Notifying parties MUST rate-limit per `{leaf, house}` pair to avoid flooding. +- **Agent-augmented verification.** When the named house publishes a brand-agent advertising [`verify_brand_claim`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) in its `supported_tasks` with a relevant `claim_type` (subsidiary / parent / property / trademark), consumers SHOULD call the agent in addition to performing crawl-based mutual-assertion inference. Apply agent results only after the [brand-authorization cross-check](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) returns verifier-local `trust: "trusted"`; a skipped or failed check cannot extend or revoke trust. The trust model is then asymmetric by direction: trusted `disputed` / `not_ours` overrides any reciprocating claim (rejection is unilateral); trusted `owned` / `pending_review` / `transferring` / `licensed_*` does NOT replace mutual-assertion verification — the reciprocating side must still confirm before relationship trust extends. When both sides have trusted brand-agent results, mutual assertion completes via two signed agent calls (`subsidiary` on the house + `parent` on the leaf). The crawl path is the fallback when the agent is unreachable, untrusted, or returns `unknown`. The email notification SHOULD continue to apply for houses without a brand-agent. See [Agent-augmented verification](#agent-augmented-verification) for the full trust table. + +## Prior art + +The mutual-assertion trust primitive mirrors the IAB Tech Lab's [`ads.txt`](https://iabtechlab.com/ads-txt/) and [`sellers.json`](https://iabtechlab.com/sellers-json/) reciprocal-publication model — and the [`app-ads.txt`](https://iabtechlab.com/wp-content/uploads/2019/03/app-ads.txt-v1.0-final-.pdf) extension that proved the pattern moves cleanly from web bundles to mobile apps. A buyer is trusted as a seller's reseller iff both sides publish the relationship at well-known URLs. Same trust shape, same non-cryptographic "who claims what about whom" verification, same fallback to one-sided / unverified for partial publication. A deployed, durable industry pattern. + +The well-known URL plus structured JSON resource discovery shape predates ads.txt — see [WebFinger](https://datatracker.ietf.org/doc/html/rfc7033) (RFC 7033) and [host-meta](https://datatracker.ietf.org/doc/html/rfc6415) (RFC 6415) for the IETF analogue. `brand.json` borrows this convention via [RFC 8615](https://datatracker.ietf.org/doc/html/rfc8615). + +Within AdCP, the [provenance verifier contract](https://github.com/adcontextprotocol/adcp/pull/3468) (seller-publishes / buyer-represents / seller-confirms) uses the same family of construction for a different field family. + +## 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.3.0-beta.3/brand-protocol/building-a-brand-agent.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/building-a-brand-agent.mdx new file mode 100644 index 00000000000..b18ef3c7ff6 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/building-a-brand-agent.mdx @@ -0,0 +1,867 @@ +--- +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.3.0-beta.3/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 | +| Identity + verification | `get_brand_identity` + `verify_brand_claim` | Nike, Inc. answering authoritative subsidiary, property, and trademark questions | +| Rights manager | `get_rights` + `acquire_rights` | Pinnacle Agency licensing talent | +| Full coverage | All five | Nova Talent managing identity, verification, 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.3.0-beta.3/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. + +## Adding verify_brand_claim + +[`verify_brand_claim`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) lets partners ask the brand-agent an authoritative yes/no question about its identity — "is this subsidiary yours", "is this property yours", "is this trademark yours". It's a layered capability on top of the identity tier: same brand data, plus the richer states (`pending_review`, `transferring`, `disputed`, `licensed_in`) the static `brand.json` can't express. + +The trust model is asymmetric by direction only after the [brand-authorization cross-check](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) returns verifier-local `trust: "trusted"`. Trusted rejections (`disputed` / `not_ours`) are authoritative unilaterally — a trusted brand has standing to refuse association without reciprocation. Trusted assertions (`owned` / `pending_review` / `transferring` / `licensed_*`) are informative but NOT trust-extending alone; the reciprocating side must still confirm. A skipped or failed cross-check cannot extend or revoke trust. This is the load-bearing concept — see [`brand.json` § Agent-augmented verification](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json#agent-augmented-verification) for the full normative table. + +### Capability declaration + +Advertise `verify_brand_claim` in `get_adcp_capabilities`, and declare which claim types you implement via the per-tool extension. A brand-agent MAY ship a slice (e.g., property only for creative-clearance, or subsidiary+parent for governance-trust extension) instead of all four. + +```typescript +server.tool("get_adcp_capabilities", {}, async () => ({ + content: [{ + type: "text", + text: JSON.stringify({ + supported_protocols: ["brand"], + supported_tasks: ["get_brand_identity", "verify_brand_claim"], + brand: { + verify_brand_claim: { + supported_claim_types: ["subsidiary", "parent", "property", "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 MUST return `UNSUPPORTED_FEATURE`. + +### State model + +The agent needs internal data corresponding to each claim type. Treat `brand.json` as the public projection of these stores — the agent serves the same facts plus the richer lifecycle states. + +| Store | Backs | Mirrors in `brand.json` | +|---|---|---| +| Subsidiary portfolio | `claim_type: "subsidiary"` | `brand_refs[]` and inline `brands[]` | +| Parent declaration | `claim_type: "parent"` | `house_domain` on the leaf's canonical document | +| Property registry | `claim_type: "property"` | `properties[]` | +| Trademark registry | `claim_type: "trademark"` | `trademarks[]` plus internal licensee-side records (which don't appear in `brand.json` today) | +| Pending-claim queue | `pending_review` lifecycle | Not represented | +| Archive | `archived` status | Not represented | + +```typescript +type SubsidiaryRecord = { + subsidiary_brand_id: string; + subsidiary_domain: string; + status: "owned" | "pending_review" | "transferring" | "disputed" | "not_ours" | "archived"; + first_observed_by_house_at: string; + expected_resolution_window_days?: number; // REQUIRED when status is "pending_review" +}; + +type PropertyRecord = { + type: "website" | "mobile_app" | "ctv_app" | "desktop_app" | "dooh" | "podcast" | "radio" | "linear_tv" | "streaming_audio" | "ai_assistant"; + identifier: string; + brand_id: string; + relationship: "owned" | "direct" | "delegated" | "ad_network"; + regions: string[]; // ISO 3166-1 alpha-2 or ["global"] + status: "owned" | "transferring" | "disputed" | "not_ours" | "archived"; + use_case_authorization?: Record; +}; + +type TrademarkRecord = { + registry: string; + number: string; + mark: string; + registration_status: "active" | "pending" | "expired" | "cancelled"; + countries: string[]; + nice_classes: number[]; + status: "owned" | "licensed_in" | "licensed_out" | "transferring" | "disputed" | "not_ours" | "archived"; + licensor_domain?: string; // when status is "licensed_in" + use_case_authorization?: Record; +}; +``` + +### Tool registration and request validation + +`verify_brand_claim` discriminates on `claim_type`. Validate the `claim` payload per type — required fields differ, and `INVALID_REQUEST` is the right response when they're missing or malformed. + +```typescript +import { z } from "zod"; + +const SubsidiaryClaim = z.object({ + subsidiary_domain: z.string().min(1), + subsidiary_brand_id: z.string().optional(), + observed_at: z.string().datetime().optional(), +}); + +const ParentClaim = z.object({ + parent_domain: z.string().min(1), + claimant_says: z.string().optional(), + observed_at: z.string().datetime().optional(), +}); + +const PropertyClaim = z.object({ + property: z.object({ + type: z.enum(["website", "mobile_app", "ctv_app", "desktop_app", "dooh", "podcast", "radio", "linear_tv", "streaming_audio", "ai_assistant"]), + identifier: z.string().min(1), + store: z.enum(["apple", "google", "amazon", "roku", "fire_tv", "samsung", "lg", "vizio", "other"]).optional(), + region: z.string().optional(), + }), + use_case: z.string().optional(), +}); + +const TrademarkClaim = z.object({ + mark: z.string().min(1), + registry: z.string().optional(), + number: z.string().optional(), + countries: z.array(z.string().length(2)).optional(), +}); + +server.tool( + "verify_brand_claim", + "Answer an authoritative yes/no about a facet of brand identity", + { + claim_type: z.enum(["subsidiary", "parent", "property", "trademark"]), + claim: z.unknown(), + }, + async ({ claim_type, claim }, extra) => { + const isAuthorized = await checkLinkedAccount(extra); + const callerId = await resolveCaller(extra); + + if (await rateLimited(callerId, claim_type, claim)) { + return rateLimitedResponse(claim_type, callerId, claim); + } + + switch (claim_type) { + case "subsidiary": { + const parsed = SubsidiaryClaim.safeParse(claim); + if (!parsed.success) return invalidInput(parsed.error); + return await answerSubsidiary(parsed.data, { isAuthorized }); + } + case "parent": { + const parsed = ParentClaim.safeParse(claim); + if (!parsed.success) return invalidInput(parsed.error); + return await answerParent(parsed.data, { isAuthorized }); + } + case "property": { + const parsed = PropertyClaim.safeParse(claim); + if (!parsed.success) return invalidInput(parsed.error); + return await answerProperty(parsed.data, { isAuthorized }); + } + case "trademark": { + const parsed = TrademarkClaim.safeParse(claim); + if (!parsed.success) return invalidInput(parsed.error); + return await answerTrademark(parsed.data, { isAuthorized }); + } + } + } +); +``` + +### Per-claim-type response shaping + +The `details` field varies by `claim_type`. Build the typed response from your internal record, then strip authorized-only fields when the caller isn't linked. + +```typescript +async function answerSubsidiary(claim, { isAuthorized, requestContext }) { + const record = await subsidiaries.findByDomain(claim.subsidiary_domain); + + if (!record) { + return signedResponse({ + claim_type: "subsidiary", + verification_status: "not_ours", + context_note: "We have no record of this brand.", + }, requestContext); + } + + const details: Record = {}; + + // Public fields + if (["owned", "pending_review", "transferring"].includes(record.status)) { + details.brand_id = record.subsidiary_brand_id; + } + + // Authorized-only fields + if (isAuthorized) { + details.first_observed_by_house_at = record.first_observed_by_house_at; + if (record.expected_resolution_window_days != null) { + details.expected_resolution_window_days = record.expected_resolution_window_days; + } + } + + // expected_resolution_window_days is REQUIRED when status is pending_review, + // even for unauthorized callers — surface the bound so they can age the answer. + if (record.status === "pending_review" && !isAuthorized) { + details.expected_resolution_window_days = record.expected_resolution_window_days; + } + + return signedResponse({ + claim_type: "subsidiary", + verification_status: record.status, + details, + }, requestContext); +} +``` + +The same shaping pattern applies to `property` (omit `details.use_case_authorization` for public callers) and `trademark` (keep `matched_registration`, `licensor_domain`, `countries`, `nice_classes` public; gate `use_case_authorization` behind authorization). + +### Public vs authorized field gating + +Mirror the public/authorized split from `get_brand_identity`. The split per claim type: + +| Public | Authorized-only | +|---|---| +| `claim_type`, `verification_status`, `context_note` (always) | `details.first_observed_by_house_at` | +| `details.brand_id`, `details.relationship`, `details.matched_registration`, `details.countries`, `details.nice_classes`, `details.regions` | `details.expected_resolution_window_days` (except when REQUIRED on `pending_review`) | +| `details.licensor_domain` (when `verification_status` is `licensed_in`) | `details.use_case_authorization` | + +Queue position, ticket state, and team routing are never exposed at any tier. + +### Aging contract for pending_review + +The agent MUST transition a `pending_review` record to a terminal status (`owned`, `disputed`, `not_ours`, `transferring`, `archived`) or to `unknown` once its `expected_resolution_window_days` elapses. Consumers SHOULD treat a stale `pending_review` response as `unknown` and fall back to crawl-based verification — but the agent owes the transition either way. + +A cron-driven sweep is the simplest implementation: + +```typescript +// Run hourly. Promotes timed-out pending_review records to unknown +// unless a human reviewer has acted in the meantime. +async function ageOutPendingClaims(): Promise { + const now = Date.now(); + const stale = await pendingClaims.findStale(now); + + for (const record of stale) { + const elapsedDays = (now - Date.parse(record.first_observed_by_house_at)) / 86_400_000; + if (elapsedDays >= record.expected_resolution_window_days) { + await pendingClaims.update(record.id, { + status: "unknown", + aged_out_at: new Date(now).toISOString(), + }); + logger.info("aged_out_pending_claim", { record_id: record.id, claim_type: record.claim_type }); + } + } +} + +setInterval(ageOutPendingClaims, 60 * 60 * 1000); +``` + +Event-driven implementations work too — schedule a deferred job at `first_observed_by_house_at + expected_resolution_window_days` when the record is created, and have the job check that no human action intervened before flipping to `unknown`. + +### Signing setup + +Responses are signed under the brand's `adcp_use: "response-signing"` JWK. **This is a distinct key from the `request-signing` key the agent uses for its own outbound calls** — per the keys-per-purpose convention, receivers enforce purpose at the JWK `adcp_use` level. Reusing a key across purposes is forbidden by the spec. + +The signature is a **JWS payload envelope** carried inside the response body (not RFC 9421 §2.2.9 transport response signing — that primitive is undefined in 3.x). `verify_brand_claim` and `verify_brand_claims` are the only tasks on the spec's [designated-task response-signing list](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) — the closed-list rule and admission criterion live there. + +The envelope is carried in the response's `signed_response` field and follows [`response-payload-jws-envelope.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/response-payload-jws-envelope.json). Populate `brand_domain` from server-side tenant resolution, not from caller input. Shared multi-brand fleets also need distinct response-signing key material and distinct `kid` values per `brand_domain`; key reuse across brands defeats tenant-bound replay analysis and is non-conformant for `adcp_use: "response-signing"`. + +AdCP 3.1 conformance uses the normal task response plus nested `signed_response`. Pre-release examples that returned the JWS envelope as the entire response body are not 3.1-conformant; verifiers MAY accept that draft shape only during private migration and MUST NOT treat it as a conformant designated-task response. + +Publish the JWK in the agent's JWKS, referenced from the relevant `agents[]` entry in `brand.json`: + +```json +// /.well-known/jwks.json on the brand-agent origin +{ + "keys": [ + { + "kty": "EC", "crv": "P-256", + "kid": "brand-agent-response-2026-01", + "x": "...", "y": "...", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "response-signing" + }, + { + "kty": "EC", "crv": "P-256", + "kid": "brand-agent-request-2026-01", + "x": "...", "y": "...", + "use": "sig", + "key_ops": ["verify"], + "adcp_use": "request-signing" + } + ] +} +``` + +```json +// /.well-known/brand.json — agents[] entry +{ + "agents": [ + { + "type": "brand", + "url": "https://brand-agent.nikeinc.com/mcp", + "id": "nikeinc_brand_agent", + "jwks_uri": "https://brand-agent.nikeinc.com/.well-known/jwks.json" + } + ] +} +``` + +In code, sign the response payload before returning it. The unsigned outer fields remain for ordinary task consumers; the signed payload is the canonical attestable object. + +```typescript +import { createHash } from "node:crypto"; +import { jcsCanonicalize, signResponseEnvelope } from "./signing"; // your library of choice + +function sha256Base64Url(input: string) { + return createHash("sha256").update(input).digest("base64url"); +} + +function signedResponse( + body: Record, + request: { + task: "verify_brand_claim" | "verify_brand_claims"; + requestBody: unknown; + callerIdentity: string | null; + resolvedBrandDomain: string; + agentUrl: string; + maxAgeSeconds: number; + } +) { + const now = Math.floor(Date.now() / 1000); + const requestBinding = { + task: request.task, + brand_domain: request.resolvedBrandDomain, + agent_url: request.agentUrl, + caller_identity: request.callerIdentity, + request: request.requestBody, + }; + const payload = { + typ: "adcp-response-payload+jws", + task: request.task, + brand_domain: request.resolvedBrandDomain, + agent_url: request.agentUrl, + request_hash: `sha256:${sha256Base64Url(jcsCanonicalize(requestBinding))}`, + iat: now, + exp: now + request.maxAgeSeconds, + response: body, + }; + const signed = signResponseEnvelope(payload, { + kid: "brand-agent-response-2026-01", + alg: "ES256", + typ: "adcp-response-payload+jws", + adcp_use: "response-signing", + }); + return { content: [{ type: "text", text: JSON.stringify({ ...body, signed_response: signed }) }] }; +} +``` + +See [request-signing](/dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing) for the keypair-generation and JWKS-publication pattern; the response-signing key follows the same shape with a different `adcp_use` tag. + +**Response-body middleware caveat.** The signed object is `signed_response.payload`, canonicalized with RFC 8785/JCS before signature verification. Whitespace and key-order changes outside that sub-object do not affect the signature, but middleware that changes `signed_response.payload`, `signed_response.protected`, or `signed_response.signature` breaks verification. If outer convenience fields are present, verifiers MUST reject the signed response when they disagree with `signed_response.payload.response`. + +### Rate limiting + +Rate-limit per `{caller_identity, claim_type, claim-target}` — a buyer hammering one trademark differs from a buyer surveying many properties, and conflating them invites either over- or under-blocking. On the limit, return `Retry-After` AND prefer returning a cached prior answer over a hard `RATE_LIMITED` error. The caller can act on a stale `owned`; they can't act on `429`. + +```typescript +const RATE_LIMIT_WINDOW_SEC = 60; +const RATE_LIMIT_MAX = 30; + +function rateLimitKey(callerId: string, claimType: string, claim: unknown): string { + const target = extractTarget(claimType, claim); // subsidiary_domain | property.identifier | mark+registry + return `${callerId}::${claimType}::${target}`; +} + +async function rateLimited(callerId: string, claimType: string, claim: unknown): Promise { + const key = rateLimitKey(callerId, claimType, claim); + const count = await counter.increment(key, RATE_LIMIT_WINDOW_SEC); + return count > RATE_LIMIT_MAX; +} + +async function rateLimitedResponse(claimType: string, callerId: string, claim: unknown) { + const cached = await responseCache.get(rateLimitKey(callerId, claimType, claim)); + if (cached) { + return { content: [{ type: "text", text: JSON.stringify({ ...cached, _from_cache: true }) }] }; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + errors: [{ code: "RATE_LIMITED", message: "Rate limit exceeded for this claim." }], + }), + }], + _meta: { "retry-after": String(RATE_LIMIT_WINDOW_SEC) }, + isError: true, + }; +} +``` + +### Cache headers per status + +Set `Cache-Control: max-age=N` on the response. Recommended values from the task page: + +| Status | max-age | +|---|---| +| `owned`, `not_ours`, `disputed` | 24–72h | +| `pending_review` | ≤1h | +| `transferring` | ≤4h | +| `licensed_in`, `licensed_out` | 24h | +| `unknown` | ≤1h | +| `use_case_authorization` present | Re-check per session | + +Consumers MAY override downward but SHOULD NOT exceed agent-supplied `max-age`. + +### Notification loop for pending_review + +When a `verify_brand_claim` call lands on an unknown subsidiary/property/trademark and the agent's policy is "ask the portfolio team", the agent enqueues a `pending_review` record AND surfaces a notification to the team. Implementation is agent-side; common patterns: + +- **Email the portfolio team** at the address on `brand.json` `contact.email`. +- **Open a ticket** in the brand's existing tracker (Jira, Linear, Zendesk). +- **Slack notify** the portfolio channel. + +The notification carries the claim payload, the caller identity, and the `expected_resolution_window_days`. A reviewer's action — accept, reject, transfer, archive — flips the record's status and the next `verify_brand_claim` call on the same claim returns the terminal answer. + +```typescript +async function enqueuePendingReview(claim: SubsidiaryClaim, callerId: string): Promise { + const record: SubsidiaryRecord = { + subsidiary_brand_id: claim.subsidiary_brand_id ?? "", + subsidiary_domain: claim.subsidiary_domain, + status: "pending_review", + first_observed_by_house_at: new Date().toISOString(), + expected_resolution_window_days: 14, + }; + await subsidiaries.create(record); + await notifications.send({ + channel: "portfolio_team", + subject: `New subsidiary claim: ${claim.subsidiary_domain}`, + body: { claim, caller: callerId, window_days: 14 }, + }); + return record; +} +``` + +### UI considerations + +When your agent returns `disputed` or `not_ours`, consumers render the rejection in their own UIs (DSP inventory shopping, portfolio explorer, creative clearance). The agent owes a clear `context_note` — that string ends up in front of humans. See [UI guidance for rejected claims](/dist/docs/3.3.0-beta.3/brand-protocol/ui-guidance) for the consumer-side conventions to keep in mind when writing your `context_note` text. + +## 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.3.0-beta.3/core/push-notification-config.json)) so buyers can submit creatives for review. See the [acquire_rights task reference](/dist/docs/3.3.0-beta.3/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: "completed", + rights_status: "rejected", + rights_id: request.rights_id, + brand_id: talent.brand_id, + 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: "completed", + rights_status: "rejected", + rights_id: request.rights_id, + brand_id: talent.brand_id, + 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: "completed", + rights_status: "acquired", + rights_id: request.rights_id, + brand_id: talent.brand_id, + /* ... terms, credentials, and rights_constraint ... */ + }; +} +``` + +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.3.0-beta.3/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.3.0-beta.3/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 { + // auth.principalId is populated from recognized per-request credentials by + // your authentication middleware. It never comes from Mcp-Session-Id. + const principalId = extra?.auth?.principalId; + if (!principalId) return false; + return await db.isLinkedAccount(principalId); +} +``` + +How you identify callers depends on your authentication setup. Your auth middleware maps recognized per-request credentials to a stable principal, and the account link is keyed by that principal. MCP session information may maintain transport continuity but MUST NOT identify or authorize the linked account. See the [authentication guide](/dist/docs/3.3.0-beta.3/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: "completed", + rights_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" }, + rights_holder: { domain: "lotientertainment.com", brand_id: "daan_janssen" }, + valid_from: "2026-03-15T00:00:00Z", + valid_until: "2026-06-15T23:59:59Z", + uses: ["likeness"], + countries: ["NL"], + impression_cap: 100000, + approval_status: "approved", + grant_status: "active", + restrictions: ["All generated creatives must be submitted for approval before distribution"], + disclosure: { + required: true, + text: "Features AI-generated likeness of Daan Janssen, used under license from Loti Entertainment", + }, + creative_approval_required: true, + content_digest: "sha256:61c68dea6be064a424741051182ba583e8b587b4c9af10c65bd57ae20fb10e79", + attestation_refs: [{ + issuer: { type: "brand", brand: { domain: "lotientertainment.com", brand_id: "daan_janssen" } }, + claim_type: "https://adcontextprotocol.org/claims/rights/grant", + subject: { + type: "resource", + resource_type: "https://adcontextprotocol.org/claims/subjects/rights-grant", + namespace: "https://rights.lotientertainment.com/mcp", + id: "rgt_dj_001", + content_digest: "sha256:61c68dea6be064a424741051182ba583e8b587b4c9af10c65bd57ae20fb10e79", + }, + locator: { type: "issuer_credential_id", credential_id: "cred_dj_001", resolver_id: "primary" }, + }], + }, +}; +``` + +### How buyers use these + +`generation_credentials` are secrets, and AdCP 3.2 does not define them as `build_creative` arguments. The buyer either uses them directly with the named generation provider or provisions them into the creative agent's authenticated, account-scoped provider integration through that implementation's secret-management channel. Do not place them in `build_creative`, `context`, or `ext`. + +The typed AdCP handoff carries the non-secret `rights_constraint` unchanged inside `creative_manifest.rights`, so the digest and issuer reference survive production, sync, package assignment, and seller readback: + +```typescript +// Buyer-side: the provider credential has already been provisioned securely. +const creative = await creativeAgent.callTool({ + name: "build_creative", + arguments: { + brand: { domain: "bistro-oranje.nl" }, + idempotency_key: "7f4024d8-b429-4ad8-92dd-128cd89f15fc", + target_capability_id: "vertical_video_builder", + message: "15-second vertical video featuring Daan Janssen endorsing Bistro Oranje", + creative_manifest: { + format_kind: "video_hosted", + assets: { + creative_brief: { + asset_type: "brief", + name: "Bistro Oranje Daan Janssen endorsement", + objective: "awareness", + }, + }, + rights: [acquireResponse.rights_constraint], + }, + }, +}); +``` + +The creative agent's provider adapter uses its securely provisioned credential and produces the asset while preserving `creative_manifest.rights`. If the buyer generated the provider asset directly instead, it attaches that asset and the unchanged rights constraint to a canonical manifest before `sync_creatives`. A downstream seller does not trust the buyer's array by inspection: it resolves and verifies an allowed credential, checks the exact digest, issuer/rights-agent authority, validity, and revocation state, and emits its own evaluation. Legacy `verification_url` values are not an authorization path. + +For the full creative manifest specification, see [creative manifests](/dist/docs/3.3.0-beta.3/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 an `agents[]` entry of `type: "brand"` whose `url` is 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 +- [ ] If implementing verification: `get_adcp_capabilities` advertises `verify_brand_claim` and `supported_claim_types`, responses are signed under a distinct `adcp_use: "response-signing"` JWK, `pending_review` records age out per the declared window, rate-limited calls return `Retry-After` and prefer cached prior answers + +## Related + +- [Brand protocol overview](/dist/docs/3.3.0-beta.3/brand-protocol/index) — How brand discovery works +- [brand.json spec](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json) — File format for brand declaration +- [get_brand_identity](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity) — Identity task reference +- [verify_brand_claim](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) — Verification task reference +- [UI guidance for rejected claims](/dist/docs/3.3.0-beta.3/brand-protocol/ui-guidance) — Consumer-side rendering of `disputed` / `not_ours` +- [get_rights](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights) — Rights discovery task reference +- [acquire_rights](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights) — Rights acquisition task reference +- [Accounts overview](/dist/docs/3.3.0-beta.3/accounts/overview) — How account linking works diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/for-advertisers.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/for-advertisers.mdx new file mode 100644 index 00000000000..10639e3b2c9 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/for-advertisers.mdx @@ -0,0 +1,82 @@ +--- +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" +--- + + +**Looking for what brand data to give AI platforms** — voice, logos, visual guidelines so platforms represent your brand correctly? See [brand.json](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json) and [monetizing AI](/dist/docs/3.3.0-beta.3/sponsored-intelligence/monetizing-ai). **This page** covers a different need: licensing real talent (athletes, musicians, influencers) for AI-generated creative. + + +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 carries the grant terms plus an issuer-bound credential reference through the supply chain. The constraint is a presentation, not proof by itself; each serving seller verifies the holder, credential, grant digest, and fresh revocation status under its own policy. +- **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.3.0-beta.3/brand-protocol/for-rights-holders.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/for-rights-holders.mdx new file mode 100644 index 00000000000..b19c0529ab6 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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.3.0-beta.3/brand-protocol/tasks/get_rights) and [rights acquisition](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/brand-protocol/index.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/index.mdx new file mode 100644 index 00000000000..dac9f91702d --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/index.mdx @@ -0,0 +1,113 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/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:** + + + + Publish which agents you operate and which you've hired, so sellers can turn impostors away. + + + How to license real talent for AI-generated campaigns. + + + How AdCP protects and monetizes talent rights. + + + How to publish seller identity, property relationships, and signing keys. + + + 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.3.0-beta.3/brand-protocol/key-concepts.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/key-concepts.mdx new file mode 100644 index 00000000000..80b7cfd4bb2 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/key-concepts.mdx @@ -0,0 +1,304 @@ +--- +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 defines machine-readable organization identity across AdCP. Buyers use `brand.json` for advertiser identity and authorized operators; sellers use it for operator identity, agent discovery, signing-key discovery, and property relationship declarations. The Property Protocol still remains the publisher authorization surface for inventory. + +| 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: { "agents": [{ "type": "brand", "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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/brand.json", + "version": "1.0", + "agents": [ + { + "type": "brand", + "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.3.0-beta.3/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.3.0-beta.3/registry/index#brand-resolution) | Fetches from Brandfetch, saves identity to registry | No `brand.json` available, need enrichment | +| [Registry lookup](/dist/docs/3.3.0-beta.3/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 an `agents[]` entry with `type: "rights"` 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", + "agents": [ + { + "type": "rights", + "url": "https://rights.lotientertainment.com/mcp", + "id": "loti_entertainment", + "available_uses": ["likeness", "voice", "endorsement"], + "right_types": ["talent"], + "countries": ["NL", "BE", "DE"] + } + ] +} +``` + +A `type: "brand"` agent provides identity data (logos, tone, assets). A `type: "rights"` agent provides licensing (discovery, pricing, acquisition). One server can play both roles, but each role needs its own URL, because `url` values in `agents[]` must be unique. The older single-object `brand_agent` and `rights_agent` fields are deprecated. + +### 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. Legacy constraints are informational metadata. In 3.2, a digest-pinned constraint carries a holder-issued credential reference, but remains only a presentation until the consuming seller verifies it and returns its own current evaluation. + +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.3.0-beta.3/brand-protocol/protect-your-brand.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/protect-your-brand.mdx new file mode 100644 index 00000000000..2350207c498 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/protect-your-brand.mdx @@ -0,0 +1,189 @@ +--- +title: Protect your brand from impersonation +sidebarTitle: Protect your brand +description: "Claim your domain, connect your brand portfolio, and publish which agents you operate and which agents you've hired, so sellers can turn impostors away. A guide for brand teams." +"og:title": "AdCP — Protect your brand from impersonation" +--- + +AI agents are starting to buy media, make creative and do business in companies' names. When one shows up at a seller saying it buys for your brand, the seller needs a way to check. Without one, a stranger's claim and your agency's claim look the same. + +You answer that question from your own domain with two public files: + +- **brand.json** says who your brand is, and which parent company it belongs to. +- **trust.json** names the **agents you operate** and the **agents you've hired**: the agencies and platforms allowed to act for you, for which brands, in which countries, and until when. + +A seller that checks these files can accept your partners and turn everyone else away. + + +**trust.json is experimental in AdCP 3.3.** It protects you only with sellers that check it, and SDK and seller support varies today. Publishing it is optional. Existing 3.x integrations keep their documented rules until they adopt it. The file name, path and schema may change before it graduates. See [trust.json (experimental)](/dist/docs/3.3.0-beta.3/reference/trust-json). + + +## See where you stand + +Enter your domain at [trustjson.org](https://trustjson.org). The lookup follows your verified parent company and shows what a seller can find today: + +| Result | What it means | +|---|---| +| **trust.json not implemented** | The organization that holds authority for this brand hasn't published a trust.json. Sellers have no public list to check. | +| **trust.json is published** | The list exists. You see the agents it lists, each grant's limits, and whether the list is exclusive. | +| **Parent company not confirmed** | The brand names a parent, but the parent doesn't list the brand back. Sellers won't use the parent's list until both sides match. | +| **Couldn't check right now** | An outage, a redirect to another site, or an invalid record. It doesn't mean the brand is unprotected. | + +Agents can run the same check through the AgenticAdvertising.org MCP server's `lookup_brand_authorization` tool, or `GET https://trustjson.org/api/trust/lookup?domain=yourbrand.com`. + +The lookup shows public declarations. It doesn't authenticate a request, test a seller's enforcement, or see the approvals a seller keeps privately. + +## Step 1: Prove you control your domain + +Everything rests on your domain. Only whoever controls `yourbrand.com` can publish files at `https://yourbrand.com/.well-known/`. + +- **Self-publish** `brand.json` at `/.well-known/brand.json` over HTTPS, or publish an `authoritative_location` pointer to a provider you control. No signup is required. +- **Claim your brand** in the [AgenticAdvertising.org registry](https://agenticadvertising.org/brand/builder) with its DNS TXT check if you want a hosted, editable entry. + +Claiming controls your registry entry. On its own, it doesn't stop impersonation at a seller. That takes the files below and a seller that checks them. + +## Step 2: Connect every brand to its parent company + +If you own several brands, you publish the list of who can act for them once, from your parent company's domain. For that to work, each brand and the parent have to vouch for each other: + +- the brand's brand.json names the parent in `house_domain`; +- the parent's brand.json lists the brand in `brand_refs`. + +At `https://nova-motors.example/.well-known/brand.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "id": "nova_motors", + "names": [{ "en": "Nova Motors" }], + "house_domain": "nova-brands.example" +} +``` + +At `https://nova-brands.example/.well-known/brand.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "house": { "domain": "nova-brands.example", "name": "Nova Brands" }, + "brand_refs": [ + { "domain": "nova-motors.example", "brand_id": "nova_motors" } + ] +} +``` + +When both sides match, sellers read the parent's trust.json for every brand in the family. **A brand without its own trust.json isn't a loophole:** its parent's list still applies, and a separate list on the brand's domain doesn't override it. + +A one-sided claim establishes nothing. Neither does a website redirect, or a corporate hierarchy a registry inferred. A standalone brand leaves out `house_domain` and publishes its own trust.json. The full rules are in the [mutual-assertion trust model](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json#mutual-assertion-trust-model). + +## Step 3: Publish who can act for you + +Publish `trust.json` at `https://{your-registrable-domain}/.well-known/trust.json`. For a portfolio, that's the parent company's domain. + +**Agents you operate** go in `agents`, one per origin, each with its role. Your own agents can act for your brands without a grant. + +**Agents you've hired** go in `grants`. Each grant names an organization by its registrable domain and sets its limits: + +| Field | What it limits | If you leave it out | +|---|---|---| +| `scopes` | Activities: `adcp:media_buying`, `adcp:creative_generation`, `adcp:measurement`, and others | Required | +| `profiles.adcp.brands` | Which of your brands | Every brand you own | +| `countries` | Markets | Global | +| `valid_from`, `valid_until` | The time window | No start or end date | +| `agents` | Specific agent origins the grantee runs | Any of the grantee's agents | + +At `https://nova-brands.example/.well-known/trust.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/trust/v1/trust.json", + "last_updated": "2026-10-25T00:00:00Z", + "exclusive_grants": true, + "agents": [ + { "url": "https://buyer.nova-brands.example", "roles": ["adcp:buying"] } + ], + "grants": [ + { + "grantee": "pinnacle-agency.example", + "scopes": ["adcp:media_buying"], + "agents": [{ "url": "https://buyer.pinnacle-agency.example" }], + "profiles": { "adcp": { "brands": ["nova_motors"] } }, + "countries": ["US", "GB"], + "valid_until": "2027-06-30T00:00:00Z" + }, + { + "grantee": "lumen-studio.example", + "scopes": ["adcp:creative_generation"], + "valid_until": "2027-06-30T00:00:00Z" + } + ] +} +``` + +Here Nova Brands runs its own in-house buyer. It lets one Pinnacle agent buy media for Nova Motors in the US and UK until mid-2027, and lets Lumen Studio generate creative for any Nova brand. Set each limit on purpose: a field you leave out is a limit you didn't set. + +**Make the list exclusive when it's complete.** With `exclusive_grants: true`, your grants are the whole list: a seller that checks turns away any agency or platform not on it, for every activity, whatever they claim. In this example, no one could measure for Nova Brands, because no grant covers measurement. Your own agents still act for you. Without exclusivity, a claim your grants don't cover is up to each seller, and a grant that almost matches doesn't change that. + +**Move everything over before you publish.** Once your trust.json exists, sellers stop reading the older `authorized_operators` list in your brand.json. Copy every entry into `grants` first. + +## Step 4: Make sure your partners list their agents + +A grant authorizes an organization. The seller still has to confirm that the agent making the call really belongs to it. Each agency or platform you hire lists the agents it runs in its own trust.json, and each agent serves its signing keys from its own origin. + +At `https://pinnacle-agency.example/.well-known/trust.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/trust/v1/trust.json", + "last_updated": "2026-10-25T00:00:00Z", + "agents": [ + { "url": "https://buyer.pinnacle-agency.example", "roles": ["adcp:buying"] } + ] +} +``` + +Ask each agency for its agent origins before you narrow a grant with `agents`. If an agency works through a hosted platform, leave your grant to the agency unnarrowed and have the agency grant the platform in its own trust.json. [Grants in trust records](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental) has the full chain rules. + +## Step 5: Test it with each seller + +Ask every seller you work with one question: **"Do you check trust.json before you accept a buy for our brand?"** Then ask them to show you one request they approved and one they turned down, checking the agent, brand, account operator, activity, country and dates. + +Seller account approval, billing and credit checks still apply on top of your list. + +## What sellers do when something is missing + +| What the seller finds | What happens | +|---|---| +| Your parent company's list, and no file on the brand's own domain | The parent's list applies. The brand is covered. | +| No trust.json has ever been published | Through AdCP 3.x, the seller may use the older brand.json list or its own verified onboarding. Absence never authorizes a stranger on its own. | +| An exclusive list with no matching grant | The request is turned away, whatever the activity. | +| A list with no matching grant, not exclusive | The seller may rely on its own verified onboarding records, not on your older brand.json list. | +| A timeout, server error or invalid file | Authorization is unavailable and spend fails closed. An outage doesn't make you unprotected. | +| A file that existed and was deleted | No fallback to older rules. Deleting the file doesn't undo it, so keep it published. | + +## Change partners, revoke access + +- **Add a partner:** add a grant. +- **Pull access:** delete or narrow the grant. To retire one of your own agents, remove it from `agents`. Sellers that check pick the change up on their next refresh, within their cache lifetime rather than instantly. Revoking an agent doesn't by itself cancel live buys or suspend an account. +- **Hand off hosting:** your agency or platform can host the file. Publish an `authoritative_location` pointer at your own well-known location, and you stay the authority. + +## Beyond advertising + +trust.json's core is protocol-neutral: agents you operate, partners you've hired, and the limits on each. Roles and scopes are namespaced, and AdCP's `adcp:` tokens are the first profile. Commerce, customer care, travel, data access and other protocols can define their own, and verifiers ignore namespaces they don't implement. Advertising is the only profile defined today. + +## Related + + + + Check any brand's published authority, with a link to claim it. + + + Schemas, fields and the experimental status contract. + + + How a seller evaluates grants, chains and exclusivity. + + + Moving from brand.json operator lists to trust.json grants. + + diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/seller-setup.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/seller-setup.mdx new file mode 100644 index 00000000000..ec191e6ec78 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/seller-setup.mdx @@ -0,0 +1,236 @@ +--- +title: Seller setup +sidebarTitle: For sellers +description: "How publishers, sales agents, networks, and SSPs use brand.json and adagents.json together for seller identity, signing-key discovery, and supply path verification." +"og:title": "AdCP — Seller setup for brand.json" +--- + +`brand.json` is not only for advertisers. On the sell side, it is the public company record for the organization operating an AdCP sales path: name, logo, domains, sales agents, and signing-key discovery. `adagents.json` is the publisher's authorization record: which properties exist and which agents may sell them. + +Buyer agents need both views. `brand.json` answers "who is this seller or platform, and what is it claiming?" `adagents.json` answers "does the publisher authorize this agent to sell this inventory?" + + +**Signing keys under the Web Bot Auth profile (3.3, experimental).** This page describes the RFC 9421 profile, where a sales agent's keys are found through brand.json `agents[].jwks_uri`. That profile is deprecated in 3.3 and works through 3.x. Under the experimental Web Bot Auth profile, the sales agent serves its own key directory at its origin (`/.well-known/http-message-signatures-directory`) and is listed, without keys, in its operator's `/.well-known/trust.json`. Each agent needs its own origin, so a platform hosting many publishers' agents on paths gives each tenant a subdomain. `adagents.json` is unchanged; a publisher's `signing_keys` pin still narrows keys by thumbprint. See [Web Bot Auth identities](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities) and the [agent identity migration guide](/dist/docs/3.3.0-beta.3/reference/migration/agent-identity-3-3). + + +## Requirement boundary + +The practical rule is: + +- **If you operate an AdCP agent**, that agent needs operator identity and signing-key discovery. Publish a `brand.json` entry for the operating organization, list the agent in `agents[]`, and expose public keys through `agents[].jwks_uri` or the default `/.well-known/jwks.json`. +- **If you publish inventory**, you need a publisher authorization record. Publish `adagents.json` on the publisher domain so buyers can verify which agents may sell which properties. +- **If you both publish inventory and operate the sales agent**, do both on the same organization/domain. +- **If you delegate sales to another operator**, your `adagents.json` is the authorization hinge. Your own `brand.json` is still recommended for publisher identity, portfolio context, and governance, but the delegated operator's `brand.json` carries the sales-agent identity buyers interact with. + +The protocol requirement is verifiability: public keys must be discoverable, and signed requests or webhooks must validate against those keys. Production agents should protect private signing keys with KMS/HSM or a managed secret system, but AdCP does not mandate a specific vendor or hosting pattern. This page covers the discovery and verification records; implementation details for key storage and signing live in [request signing](/dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing). + +## Who publishes what + +| Organization | Publish `brand.json`? | Publish `adagents.json`? | Why | +|---|---|---|---| +| Publisher selling direct | Yes | Yes | The publisher is both the inventory owner and the sales operator. Buyers verify its identity, sales endpoint, keys, and property authorization from the same organization. | +| Publisher delegating sales | Recommended | Yes | The publisher's `adagents.json` authorizes the external sales agent. Its `brand.json` gives buyers the publisher's own identity and house/portfolio context, but the external operator's `brand.json` carries the sales-agent identity. | +| Network, SSP, or sales representative | Yes | Usually no, unless it also owns properties | The operator's `brand.json` declares its sales agent, signing keys, and represented properties. The publisher confirms the relationship in its own `adagents.json`. | + +If the publisher and sales operator are the same company, both files can live on the same domain. If a third-party platform sells for the publisher, the operator's `brand.json` lives on the operator domain and the publisher's `adagents.json` lives on the publisher domain. + +## How verification works + +The sell-side chain is bilateral: + +1. The seller's `brand.json` declares the sales agent in `agents[]`. +2. The seller's `brand.json` declares the properties it owns, sells directly, manages, or represents in `properties[]`. +3. The publisher's `adagents.json` declares the same sales agent in `authorized_agents[]`. +4. For direct, delegated, or network paths, the `relationship` value in `brand.json` matches the publisher's `delegation_type` in `adagents.json`. For first-party inventory, `relationship: "owned"` is inline ownership and has no `delegation_type` counterpart. +5. The signing key used by the agent is discoverable from the seller's `brand.json` `agents[].jwks_uri`; for mutating seller authorizations, the publisher also pins allowed keys in `adagents.json` `authorized_agents[].signing_keys[]`. A pin only narrows: a webhook key must be in the seller's JWKS and match the pin. + +The result is a verifiable supply path. The operator publicly says "I sell this property"; the publisher publicly says "this operator is authorized to sell it." + +## Direct publisher example + +A publisher with its own sales agent publishes `brand.json` at `https://streamhaus.example/.well-known/brand.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "id": "streamhaus", + "url": "https://streamhaus.example", + "names": [{ "en_US": "StreamHaus" }], + "industries": ["media"], + "properties": [ + { + "type": "ctv_app", + "identifier": "com.streamhaus.ctv", + "relationship": "owned" + } + ], + "agents": [ + { + "type": "sales", + "id": "streamhaus_sales", + "url": "https://ads.streamhaus.example/mcp", + "jwks_uri": "https://ads.streamhaus.example/.well-known/jwks.json" + } + ] +} +``` + +The same publisher publishes `adagents.json` at `https://streamhaus.example/.well-known/adagents.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/adagents.json", + "contact": { + "name": "StreamHaus", + "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": "bundle_id", "value": "com.streamhaus.ctv" }] + } + ], + "authorized_agents": [ + { + "authorization_type": "property_ids", + "url": "https://ads.streamhaus.example/mcp", + "authorized_for": "StreamHaus direct CTV inventory", + "property_ids": ["streamhaus_ctv"], + "signing_keys": [ + { + "kid": "streamhaus-sales-prod-2026", + "kty": "OKP", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "w8zcY1LZqV4n1oKbfyq3n2q3sL2uV3z7kEw1m9Qjv4A", + "use": "sig" + } + ] + } + ] +} +``` + +The buyer verifies that `agents[].url` in `brand.json` matches `authorized_agents[].url` in `adagents.json`, that StreamHaus owns the property it claims, and that the agent's signed webhook deliveries use a key that is both in the agent's JWKS and pinned by StreamHaus in `signing_keys[]`. Synchronous responses carry no transport signature; TLS to the agent URL covers those. + +## Delegated seller example + +When a network sells another publisher's inventory, the network publishes its own `brand.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "id": "northwind_media", + "url": "https://northwind.example", + "names": [{ "en_US": "Northwind Media" }], + "industries": ["advertising"], + "properties": [ + { + "type": "website", + "identifier": "streamhaus.example", + "relationship": "delegated" + } + ], + "agents": [ + { + "type": "sales", + "id": "northwind_sales", + "url": "https://northwind.example/mcp", + "jwks_uri": "https://northwind.example/.well-known/jwks.json" + } + ] +} +``` + +StreamHaus confirms the relationship in `https://streamhaus.example/.well-known/adagents.json`: + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/adagents.json", + "contact": { + "name": "StreamHaus", + "email": "adops@streamhaus.example", + "domain": "streamhaus.example" + }, + "properties": [ + { + "property_id": "streamhaus_web", + "property_type": "website", + "name": "StreamHaus", + "publisher_domain": "streamhaus.example", + "identifiers": [{ "type": "domain", "value": "streamhaus.example" }] + } + ], + "authorized_agents": [ + { + "authorization_type": "property_ids", + "url": "https://northwind.example/mcp", + "authorized_for": "StreamHaus inventory via delegated sales agreement", + "property_ids": ["streamhaus_web"], + "delegation_type": "delegated", + "signing_keys": [ + { + "kid": "northwind-sales-prod-2026", + "kty": "OKP", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "Xe2lAKRJR_zr3FQRdSNwp3zsrv_IXnVCWJXDcWXwkLI", + "use": "sig" + } + ] + } + ] +} +``` + +Northwind's `brand.json` alone is not authorization. Any operator can claim a property. The publisher's matching `adagents.json` entry is what turns the claim into an authorized supply path. + +### Multi-tenant sales agents + +Operators that host one sales-agent deployment for many publisher tenants MAY publish one `agents[]` entry per tenant or property-scoped endpoint, even when every entry has `type: "sales"`. Each entry is selected by its concrete `url`, not by `type`, so a path-routed deployment can publish per-tenant JWKS shards. The tenant or property scope is carried by the concrete agent URL being authenticated, such as `/mcp/{tenant}`; it is not selected from a tenant identifier inside the signed request body. Agent URLs MUST be unique within the `agents[]` array. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand.json", + "version": "1.0", + "agents": [ + { + "type": "sales", + "id": "sales_streamhaus", + "url": "https://northwind.example/mcp/streamhaus", + "jwks_uri": "https://northwind.example/.well-known/jwks/streamhaus.json" + }, + { + "type": "sales", + "id": "sales_pinnacle_news", + "url": "https://northwind.example/mcp/pinnacle_news", + "jwks_uri": "https://northwind.example/.well-known/jwks/pinnacle_news.json" + } + ] +} +``` + +Signature verifiers MUST resolve keys from the agent URL being authenticated using [Agent resolution](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#agent-resolution): match exactly one `brand.json` `agents[].url` entry by canonical URL, use that entry's `jwks_uri` or the default `/.well-known/jwks.json` on the origin of the entry's `url`, apply any publisher pin as a narrowing intersection, then resolve `keyid`. They MUST reject duplicate matching entries as ambiguous, MUST NOT choose a JWKS by agent `type` alone, and MUST NOT rely on a tenant identifier supplied inside the request payload to decide which key set to trust. + +## Setup checklist + +1. Publish `brand.json` for each organization that operates an AdCP sales agent at `https://{seller-domain}/.well-known/brand.json`. +2. Add a `sales` entry to `agents[]` for each AdCP sales endpoint the organization operates. +3. Publish the endpoint's JWKS through `agents[].jwks_uri`, or rely on the default `/.well-known/jwks.json` on the agent origin. +4. Add every owned, direct, delegated, or network-represented property to `properties[]` with the right `relationship`; use `owned` for first-party inventory. +5. Publish `adagents.json` on each publisher domain that owns the inventory. +6. In `authorized_agents[]`, list the seller's agent URL, authorization scope, matching `delegation_type` for direct, delegated, or network paths, and `signing_keys[]` for any mutating seller authorization. +7. Keep both files aligned when a sales relationship, endpoint, or signing key starts, changes, or ends. + +## Where to go next + +- [brand.json reference](/dist/docs/3.3.0-beta.3/brand-protocol/brand-json) — field-level details for `agents[]`, `properties[]`, and property relationships +- [adagents.json reference](/dist/docs/3.3.0-beta.3/governance/property/adagents) — publisher-side authorization and `delegation_type` +- [Seller verification](/dist/docs/3.3.0-beta.3/verification/overview) — buyer-side walkthrough of the full verification chain +- [Seller integration guide](/dist/docs/3.3.0-beta.3/building/operating/seller-integration) — full AdCP sales-agent implementation path diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights.mdx new file mode 100644 index 00000000000..4d6286f1372 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights.mdx @@ -0,0 +1,342 @@ +--- +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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights), `acquire_rights`, [`update_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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.3.0-beta.3/reference/experimental-status) for the full contract. + + +Binding contractual request to acquire rights from a brand agent. Parallels [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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.3.0-beta.3/brand/acquire-rights-request.json) +- **Response**: [`acquire-rights-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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_kinds": ["video_hosted"], + "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", + "rights_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" }, + "rights_holder": { "domain": "lotientertainment.com", "brand_id": "daan_janssen" }, + "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", + "grant_status": "active", + "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" + }, + "creative_approval_required": true, + "content_digest": "sha256:29dbd75733e150d48a8c594966d8eb52b7e3ebdc21d350f6e2d0031800a0ba74", + "attestation_refs": [{ + "issuer": { "type": "brand", "brand": { "domain": "lotientertainment.com", "brand_id": "daan_janssen" } }, + "claim_type": "https://adcontextprotocol.org/claims/rights/grant", + "subject": { + "type": "resource", + "resource_type": "https://adcontextprotocol.org/claims/subjects/rights-grant", + "namespace": "https://agent.lotientertainment.com/mcp", + "id": "janssen_likeness_voice", + "content_digest": "sha256:29dbd75733e150d48a8c594966d8eb52b7e3ebdc21d350f6e2d0031800a0ba74" + }, + "locator": { "type": "issuer_credential_id", "credential_id": "cred_janssen_2026q2", "resolver_id": "primary" } + }] + }, + "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", + "rights_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", + "rights_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", + "rights_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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights) | +| `pricing_option_id` | string | Yes | Selected pricing option | +| `buyer` | brand-ref | Yes | Buyer's brand identity | +| `account` | account-ref | Conditional | Account context. Pass when the brand agent has a governance agent bound to this brand+operator via [`sync_governance`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) — the brand agent uses it to resolve the bound governance agent and project commitment under the [CPM-priced rights validation](#cpm-priced-rights-under-a-governance-plan). Optional when the request is ungoverned or threads a `governance_context` token on the envelope instead. | +| `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_kinds` | string[] | No | Canonical creative format kinds that will be produced | +| `campaign.format_option_refs` | FormatOptionRef[] | No | Exact publisher/product options when kind alone is insufficient | +| `campaign.format_ids` | format-id[] | No | Deprecated 3.x named-format compatibility field | +| `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.3.0-beta.3/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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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. + +Like [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy), rights grants are not time-shiftable for elapsed windows: 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 + +A request is **governance-aware** when the brand agent will project commitment against a governance plan before issuing credentials. That happens by either of two paths: + +1. **Inline path** — the request carries an intent-phase `governance_context` token on the [protocol envelope](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security). The buyer threads the token explicitly per request. +2. **Bound path** — the request carries `account` (a seller-assigned `account_id`, or `account.brand` + `account.operator` resolving to a bound account), and the brand agent has a governance agent previously bound to that account via [`sync_governance`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance). The brand agent looks up the bound agent without the buyer threading anything per request. + +When **both** paths are present in the same request — an inline `governance_context` token AND an `account` with a bound governance agent — the inline token wins. The token is per-request, JWS-signed against a specific plan, and is the [primary correlation key](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security) for audit and reporting; the bound agent serves as the resolver fallback when no token is threaded. Brand agents MUST consult the agent identified by the inline token when both are present, even if it differs from the bound agent — the buyer's per-request decision overrides the persisted binding. + +Both paths trigger the same projection rule. When the request is governance-aware 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 the request is governance-aware (either path), 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. + +Ungoverned requests — neither `governance_context` token nor an account with a bound governance agent — 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 [Spend-commit invocation](/dist/docs/3.3.0-beta.3/governance/campaign/specification#spend-commit-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 digest-pinned constraint and issuer-bound attestation references for creative manifests. Embed it unchanged in the creative's `rights` array. | + +When the grant limits where the creative may run, the constraint carries an experimental advisory `channels[]` and/or `excluded_channels[]` restriction (values from the [media channel taxonomy](/dist/docs/3.3.0-beta.3/reference/media-channel-taxonomy), mirroring `countries` and `excluded_countries`). An `excluded_channels`-only list means every channel except those excluded; `channels` is a positive grant, less anything also in `excluded_channels`; an empty `channels` grants no channel; omitting both means no channel restriction. The rights agent populates it from the grant terms. It is advisory metadata: orchestrators and serving parties SHOULD NOT place the creative outside the effective set, and creative agents SHOULD check it before building (see [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative#rights-channel-restrictions)). It is covered by the constraint's `content_digest`, so embed it unchanged. + +## 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 unchanged in the creative asset or manifest `rights` array. The rights agent pre-builds the exact grant digest and portable references; restrictions, disclosure obligations, creative-approval requirements, and every other serving-relevant term are inside that signed projection. Modifying or omitting one invalidates the digest binding. Webhook credentials remain outside the projection. +3. **Approve**: POST a [`creative-approval-request`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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.3.0-beta.3/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**: The serving seller evaluates at least one rights attestation under its own published policy, requires a current `verified` result when its capability says `required`, and separately enforces country, use, date, and impression-cap policy. The advisory `channels` / `excluded_channels` restriction is a SHOULD NOT in 3.3: a verified attestation does not make it mandatory, and a holder who needs a term enforced states it in `restrictions`. A legacy `verification_url` never authorizes delivery. +5. **Report**: Use [`report_usage`](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/by-layer/L3/webhooks#signature-verification) — RFC 9421 by default (rights agent signs with its `adcp_use: "request-signing"` key published at its brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window), 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`](/dist/docs/3.3.0-beta.3/accounts/tasks/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/brand-protocol/tasks/get_brand_identity.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity.mdx new file mode 100644 index 00000000000..17c5a067b10 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity.mdx @@ -0,0 +1,239 @@ +--- +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.3.0-beta.3/brand/get-brand-identity-request.json) +- **Response**: [`get-brand-identity-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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.3.0-beta.3/accounts/tasks/sync_accounts)) 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.3.0-beta.3/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, collections | +| **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", + "rights_offering_id": "loti_dj_voice_2026", + "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`, `collections` (experimental) + +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`, optional `rights_offering_id`). The offering ID is advisory provenance from [`get_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights), not a buyer-specific grant; resolve it through [`acquire_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights) before synthesis. | +| `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`) | +| `collections` | object[] | No | Experimental (`brand.collection_links`). The person's or brand's channels, mirroring brand.json brand-level `collections`: `publisher_domain`, `collection_id` and `name` (required), plus optional `role` and `seller_agent_url`. `publisher_domain` plus `collection_id` form the selector a product uses. `seller_agent_url` is a routing hint, not authorization evidence; only the publisher's `adagents.json` says which agent may sell the collection. Public. | +| `available_fields` | string[] | No | Sections available but not returned due to authorization level. Tells the caller what linking their account would unlock. | + +### People behind collections + +A collection's `talent[].brand_ref` points at a person's brand identity. The collection carries the channel facts; the person's details come from their brand or rights agent through this task. The person's picture is in `logos`, which is always public. brand.json's `avatar` is not exposed here: it is a synthesis configuration (`provider`, `avatar_id`, `settings`), not a profile picture. `collections` (experimental, `brand.collection_links`) links back from the person to their channels, so a buyer can go from person to channel to product selector. A collection's `brand_ref` is the publisher's claim; buyers SHOULD treat it as confirmed only when the person's `collections` lists the same `{publisher_domain, collection_id}` back. Displaying the person's picture from `logos` is not a likeness license; likeness use in ads goes through [`get_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights) and [`acquire_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights). Brand agents that return `collections` list `brand.collection_links` in `experimental_features`: + +```json +{ + "brand_id": "jordan_vega", + "house": { "domain": "jordanvega.example.com", "name": "Jordan Vega Media" }, + "names": [{ "en": "Jordan Vega" }], + "logos": [{ "url": "https://cdn.jordanvega.example.com/headshot.jpg", "variant": "primary" }], + "collections": [ + { + "publisher_domain": "acmestreaming.example.com", + "collection_id": "pinnacle_challenge", + "name": "The Pinnacle Challenge", + "role": "host", + "seller_agent_url": "https://sales.acmestreaming.example.com/adcp" + } + ] +} +``` + +## 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.3.0-beta.3/brand-protocol/tasks/get_rights.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights.mdx new file mode 100644 index 00000000000..86f24301b32 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights), [`update_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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.3.0-beta.3/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.3.0-beta.3/brand/get-rights-request.json) +- **Response**: [`get-rights-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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`](/dist/docs/3.3.0-beta.3/accounts/tasks/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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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.3.0-beta.3/brand-protocol/tasks/get_brand_identity) for the selected brand's full identity data +2. Call [`acquire_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights) with the `rights_id` and `pricing_option_id` diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/tasks/search_brands.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/search_brands.mdx new file mode 100644 index 00000000000..ba1337bca30 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/search_brands.mdx @@ -0,0 +1,68 @@ +--- +title: search_brands +description: "Discover brands on a brand agent's roster before retrieving identity or rights details." +"og:title": "AdCP — search_brands" +--- + +`search_brands` is an experimental, read-only discovery task. It returns ranked public brand stubs from a brand agent's roster. Use the returned `brand_id` with [`get_brand_identity`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity) for full identity data or [`get_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights) for licensing terms. + +## Schema + +- **Request**: [`search-brands-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand/search-brands-request.json) +- **Response**: [`search-brands-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand/search-brands-response.json) + +## Request + +```json +{ + "query": "automotive brands with electric vehicles", + "countries": ["US"], + "pagination": { "max_results": 20 }, + "context": { "correlation_id": "brand-search-001" } +} +``` + +`query` is required. Callers can additionally constrain results with `industries`, `countries`, or `buyer_brand`. When `buyer_brand` is supplied, the agent should omit brands whose licensing restrictions make them incompatible. + +## Response + +```json +{ + "brands": [ + { + "brand_id": "nova_motors", + "house": { + "domain": "novamotors.example", + "name": "Nova Motors" + }, + "relationship_trust": "mutual", + "relationship_verified_at": "2026-08-15T12:00:00Z", + "names": [{ "en-US": "Nova Motors" }], + "description": "Electric mobility and vehicle technology", + "industries": ["automotive"], + "keller_type": "master", + "rights": { + "available_uses": ["commercial"], + "countries": ["US"] + } + } + ], + "context": { "correlation_id": "brand-search-001" } +} +``` + +An empty `brands` array is a successful response when no roster entry matches. Results are lightweight public stubs; authorized assets, detailed identity fields, and pricing remain on the follow-up tasks. + +## Fields + +| Field | Required | Description | +|---|---|---| +| `query` | Yes | Natural-language search, up to 2,000 characters | +| `industries` | No | Advertiser-industry filters | +| `countries` | No | ISO 3166-1 alpha-2 market filters | +| `buyer_brand` | No | Buyer brand used for compatibility filtering | +| `pagination` | No | Page size and continuation cursor | + +Each result includes `brand_id` and localized `names`; `house` is present when a house affiliation is on record. `relationship_trust` reports whether that affiliation is reciprocated: only `mutual` and `inline` extend trust to `house.domain`. `leaf_only`, `house_only`, and `unverifiable` are one-sided or unverified states, while `standalone` means the brand declares no house. A missing `relationship_trust` means the agent has not computed a verdict and MUST NOT be interpreted as `standalone`. `claimed_house_domain` preserves a leaf's unverified parent claim, and `relationship_verified_at` records the last bilateral confirmation for `mutual` results. + +Other public discovery fields such as description, industries, logos, architecture type, and rights availability are optional. diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/tasks/update_rights.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/update_rights.mdx new file mode 100644 index 00000000000..e0f7e576fab --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/update_rights.mdx @@ -0,0 +1,172 @@ +--- +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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_rights), [`acquire_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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.3.0-beta.3/brand/update-rights-request.json) +- **Response**: [`update-rights-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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" }, + "rights_holder": { "domain": "lotientertainment.com", "brand_id": "daan_janssen" }, + "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", + "grant_status": "active", + "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" + }, + "creative_approval_required": true, + "content_digest": "sha256:590b38bde70da8e40644a5b96e3a3b2a60f7cbc3d450742cef259a0f1261cada", + "attestation_refs": [{ + "issuer": { "type": "brand", "brand": { "domain": "lotientertainment.com", "brand_id": "daan_janssen" } }, + "claim_type": "https://adcontextprotocol.org/claims/rights/grant", + "subject": { + "type": "resource", + "resource_type": "https://adcontextprotocol.org/claims/subjects/rights-grant", + "namespace": "https://agent.lotientertainment.com/mcp", + "id": "janssen_likeness_voice", + "content_digest": "sha256:590b38bde70da8e40644a5b96e3a3b2a60f7cbc3d450742cef259a0f1261cada" + }, + "locator": { "type": "issuer_credential_id", "credential_id": "cred_janssen_2026q3", "resolver_id": "primary" } + }] + }, + "implementation_date": "2026-06-28T14:30:00Z" +} +``` + + +## Parameters + +### Request + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `rights_id` | string | Yes | Rights grant identifier from [`acquire_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights) | +| `account` | account-ref | Conditional | Account context. Pass when the brand agent has a governance agent bound to this brand+operator via [`sync_governance`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) — `update_rights` is a [modification-phase governance trigger](/dist/docs/3.3.0-beta.3/governance/campaign/specification#spend-commit-invocation), and the brand agent uses `account` to resolve the bound agent when computing the incremental commit delta. When both an inline `governance_context` token and a bound agent are present, the inline token wins. | +| `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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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 digest-pinned constraint and replacement attestation references for re-embedding in creatives | +| `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` replaces the old constraint in every active creative. The rights agent revokes or supersedes the old credential and issues a new digest/reference pair; changing terms without replacing the credential cannot produce a verified result. Sellers re-evaluate the replacement before continued delivery according to their `media_buy.rights_attestations` policy. + +## Next steps + + + + The initial rights acquisition flow. + + + Report impressions against rights grants. + + diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim.mdx new file mode 100644 index 00000000000..a576cfdb905 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claim.mdx @@ -0,0 +1,398 @@ +--- +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`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/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.3.0-beta.3/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 trusted rejections (`disputed` / `not_ours`) are authoritative on a single signed response** — after the [brand-authorization cross-check](#trust-model) returns verifier-local `trust: "trusted"`, the brand has standing to refuse association unilaterally. An untrusted rejection cannot revoke trust. + +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.3.0-beta.3/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.3.0-beta.3/brand/verify-brand-claim-request.json) +- **Response**: [`verify-brand-claim-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/brand/verify-brand-claim-response.json) + +## Capability discovery + +The brand-agent advertises this task in its [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/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_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) (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_FEATURE` cleanly. + +## Authorization tiers + +The public/authorized split per claim type: + +| Tier | What the agent returns | +|---|---| +| **Public** (no linked account) | `claim_type`, `verification_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.3.0-beta.3/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 only after the [brand-authorization cross-check](#trust-model) returns verifier-local `trust: "trusted"`; no reciprocation is then 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 | `verification_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 `verification_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 | `verification_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 only after the [brand-authorization cross-check](#trust-model) returns verifier-local `trust: "trusted"`: + +```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`, `linear_tv`, `streaming_audio`, `ai_assistant`. | +| `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 | `verification_status` ∈ {`owned`, `transferring`} | `owned` / `direct` / `delegated` / `ad_network` — mirrors brand.json's `properties[].relationship`. | +| `brand_id` | Public | `verification_status` ∈ {`owned`, `transferring`} | The brand within the house that owns this property. | +| `regions` | Public | `verification_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 only after the [brand-authorization cross-check](#trust-model) returns verifier-local `trust: "trusted"`: + +```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 `verification_status` is `owned`, `licensed_in`, `licensed_out`, or `transferring`. | +| `licensor_domain` | Public | Domain of the licensor when `verification_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.3.0-beta.3/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 during the envelope's `iat`/`exp` window; it is not a non-repudiation receipt and does not bind the brand to assertions beyond the direction-asymmetric semantics below. The required `signed_response` envelope binds the response to the designated task, resolved `brand_domain`, responding `agent_url`, caller identity, request payload, and freshness window. Verifiers that rely on the signature MUST validate the envelope against [`response-payload-jws-envelope.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/response-payload-jws-envelope.json), reject expired envelopes for online decisions, and reject any mismatch between unsigned task-body fields and `signed_response.payload.response`. + +A valid signature proves authorship, not brand authority. For 3.2, verifiers SHOULD run the additive [brand-authorization cross-check](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) and represent its result with [`brand-response-authorization-result.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/brand-response-authorization-result.json). A failed or unavailable cross-check yields verifier-local `trust: "untrusted"`; it does not rewrite `verification_status` and MUST NOT extend or revoke relationship trust on its own. This applies to both assertion and rejection directions. Mandatory cryptographic brand authorization and hard rejection are reserved for 4.0. + +For a verifier that performs the 3.2 cross-check, the direction-asymmetric rules below apply only when the verifier-local result is `trust: "trusted"`. An `untrusted` response remains unauthenticated evidence regardless of direction and cannot extend or revoke relationship trust. + +The trust model for a trusted response is **direction-asymmetric**: + +- **Rejection direction** (agent says `disputed` / `not_ours`) is authoritative only after the signer passes the brand-authorization cross-check. A trusted 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.3.0-beta.3/brand-protocol/brand-json#mutual-assertion-trust-model). + +See [`brand.json` § Agent-augmented verification](/dist/docs/3.3.0-beta.3/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`. + +Agents SHOULD set `signed_response.payload.exp` no later than the HTTP freshness lifetime implied by `Cache-Control: max-age`. Consumers using the signed response for online decisions MUST use the earlier of the HTTP cache expiry and the signed `exp`; after that point the envelope remains audit evidence only, not a fresh authorization signal. + +## Error handling + +| Error code | Cause | +|---|---| +| [`AUTH_INVALID`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-auth-invalid) | Caller's signed envelope did not verify. | +| [`RATE_LIMITED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) | The agent does not implement the requested `claim_type`. Check `supported_claim_types` via [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities). | +| [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) | Required `claim` fields are missing or malformed (e.g., `subsidiary_domain` not a valid hostname). | +| [`VALIDATION_ERROR`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-validation-error) | For `claim_type: "trademark"` — multiple registrations match. Narrow with `registry`, `number`, or `countries`. | + +```json +{ + "errors": [ + { + "code": "UNSUPPORTED_FEATURE", + "message": "claim_type 'property' is not supported by this agent. Supported: subsidiary, parent, trademark." + } + ] +} +``` diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claims.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claims.mdx new file mode 100644 index 00000000000..3ba1cc49235 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/tasks/verify_brand_claims.mdx @@ -0,0 +1,163 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/brand/verify-brand-claims-request.json) +- **Response**: [`verify-brand-claims-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature) for one of many claims, a [`VALIDATION_ERROR`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-validation-error) for an ambiguous 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": "VALIDATION_ERROR", "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_FEATURE`, [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) for malformed input in one item, `VALIDATION_ERROR` for an ambiguous match | +| Batch-level | top-level `errors[]` | [`AUTH_INVALID`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-auth-invalid), [`RATE_LIMITED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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 only after the envelope receives verifier-local `trust: "trusted"`: trusted rejections (`disputed` / `not_ours`) are authoritative, while trusted assertions (`owned` / `pending_review` / `transferring` / `licensed_*`) require reciprocation before extending positive trust. An `untrusted` envelope cannot extend or revoke relationship trust in either direction. + +One top-level `signed_response` envelope attests the full `results[]` array. Its `request_hash` binds the entire `claims[]` request, caller identity, resolved `brand_domain`, and responding `agent_url`; there is no per-result signature. Consumers using signed bulk results for online decisions apply the same freshness rule as the single-target task: use the earlier of HTTP cache expiry and `signed_response.payload.exp`. Audit stores that extract one rejected result from a bulk response MUST also retain the original batch request and result index, because the envelope verifies the whole batch rather than a standalone per-result artifact. + +The single-target [brand-authorization cross-check](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) applies to the one batch envelope. Batch verifiers SHOULD deduplicate brand.json resolution by `brand_domain` and JWKS resolution by canonical resolved `jwks_uri` within a call, then attach one verifier-local authorization result to the envelope. An unavailable or failed binding makes every result in that envelope `untrusted`; it does not alter the signer-asserted per-result `verification_status` values. + +**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.3.0-beta.3/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.3.0-beta.3/brand-protocol/ui-guidance.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/ui-guidance.mdx new file mode 100644 index 00000000000..356825dbfc8 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/ui-guidance.mdx @@ -0,0 +1,164 @@ +--- +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.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) returns `disputed` or `not_ours` and the [brand-authorization cross-check](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#designated-task-response-signing) returns verifier-local `trust: "trusted"`, the rejection is authoritative — a trusted brand has standing to refuse association unilaterally. A skipped or failed cross-check is untrusted and MUST NOT trigger relationship revocation. For a trusted rejection, 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 `verification_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. +- **`verification_status`**, **`context_note`**, and the `claim` payload that triggered it. +- **Bulk context**, when the answer came from `verify_brand_claims`: the full original `claims[]` request and the rejected result index. The single bulk envelope signs the whole `results[]` array, not a standalone per-result artifact. +- **Cache validity window**: the earlier of `Cache-Control: max-age` and `signed_response.payload.exp`, so you know when the record stopped being fresh. + +A signed envelope is durable historical evidence when its signature verifies, its `request_hash` matches the triggering request, and the rejected claim falls within the envelope's `iat`/`exp` window. If a buyer is challenged for excluding a property based on a house-brand rejection, that verified envelope 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.3.0-beta.3/brand-protocol/tasks/verify_brand_claim) — The task this guidance applies to +- [Building a brand agent](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/brand-protocol/brand-json#agent-augmented-verification) — The asymmetric trust model that makes rejections authoritative diff --git a/dist/docs/3.3.0-beta.3/brand-protocol/walkthrough-rights-licensing.mdx b/dist/docs/3.3.0-beta.3/brand-protocol/walkthrough-rights-licensing.mdx new file mode 100644 index 00000000000..fcab8acc128 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/brand-protocol/walkthrough-rights-licensing.mdx @@ -0,0 +1,600 @@ +--- +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 `type: "rights"` entry in `agents[]` 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.3.0-beta.3/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"], + "agents": [ + { + "type": "rights", + "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 a 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_kinds": ["video_hosted", "image"], + "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", + "rights_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" + }, + "rights_holder": { "domain": "lotientertainment.com", "brand_id": "daan_janssen" }, + "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", + "approval_status": "approved", + "grant_status": "active", + "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." + }, + "creative_approval_required": true, + "content_digest": "sha256:4feebc941c805632ab259451fd1cda47fc56985dc62e177844ebfd377556ab7a", + "attestation_refs": [{ + "issuer": { "type": "brand", "brand": { "domain": "lotientertainment.com", "brand_id": "daan_janssen" } }, + "claim_type": "https://adcontextprotocol.org/claims/rights/grant", + "subject": { + "type": "resource", + "resource_type": "https://adcontextprotocol.org/claims/subjects/rights-grant", + "namespace": "https://rights.lotientertainment.com/mcp", + "id": "loti_dj_talent_2026", + "content_digest": "sha256:4feebc941c805632ab259451fd1cda47fc56985dc62e177844ebfd377556ab7a" + }, + "locator": { "type": "issuer_credential_id", "credential_id": "cred_loti_dj_2026", "resolver_id": "primary" } + }] + } +} +``` + + + +--- + +## 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", + "rights_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", + "rights_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", + "rights_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 unchanged — the buyer does not construct or re-sign it. A single ad can combine talent likeness from one rights holder and music from another, each with different validity periods and geographic restrictions. The serving seller evaluates each issuer-bound reference under its own allowlist and binds the result to the exact grant digest. A deprecated `verification_url`, if present on legacy data, is ignored for authorization and is never fetched as a revocation oracle. + +### 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.3.0-beta.3/building/by-layer/L0/a2a-guide.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-guide.mdx new file mode 100644 index 00000000000..5db8c72bf06 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-guide.mdx @@ -0,0 +1,1042 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/building/by-layer/L0/a2a-response-extraction)) accept both shapes during the compatibility period. + +## A2A Client Setup + +Deterministic AdCP invocation on A2A 1.0 uses the [AdCP A2A Profile Extension v3](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension), identified by `https://adcontextprotocol.org/extensions/adcp/v3`. Clients activate it on every request with `A2A-Extensions`. + +### 1. Initialize an A2A 1.0 client + +```javascript +const endpoint = 'https://seller.example/a2a/jsonrpc'; +const profileUri = 'https://adcontextprotocol.org/extensions/adcp/v3'; + +function normalizeState(state) { + return state?.replace(/^TASK_STATE_/, '').toLowerCase().replaceAll('_', '-'); +} + +// Minimal JSON-RPC binding used by this guide. Production SDKs should +// encapsulate the same headers, messageId generation, and response unwrapping. +const a2a = { + async getAgentCard() { + const response = await fetch('https://seller.example/.well-known/agent-card.json'); + if (!response.ok) throw new Error(`Agent Card failed: ${response.status}`); + return response.json(); + }, + async send({ message, configuration }) { + const wireMessage = { + ...message, + messageId: message.messageId ?? crypto.randomUUID() + }; + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'authorization': `Bearer ${process.env.ADCP_API_KEY}`, + 'A2A-Version': '1.0', + 'A2A-Extensions': profileUri + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method: 'SendMessage', + params: { message: wireMessage, ...(configuration && { configuration }) } + }) + }); + const envelope = await response.json(); + if (envelope.error) throw new Error(envelope.error.message); + const task = envelope.result.task; + if (!task) throw new Error('Expected the SendMessage response task branch'); + const summary = task.status.message?.parts?.find(part => typeof part.text === 'string')?.text + ?? task.artifacts?.[0]?.parts?.find(part => typeof part.text === 'string')?.text; + return { + ...task, + taskId: task.id, + a2aStatus: task.status, + status: normalizeState(task.status.state), + message: summary + }; + } +}; +``` + +### 2. Verify Agent Card + +```javascript +// Check available skills +const agentCard = await a2a.getAgentCard(); +const adcpProfile = agentCard.capabilities.extensions?.find( + ext => ext.uri === 'https://adcontextprotocol.org/extensions/adcp/v3' +); +if (!adcpProfile) throw new Error('Agent does not advertise the AdCP A2A profile'); +console.log(agentCard.skills.map(s => s.id)); +// ["get_products", "create_media_buy", "sync_creatives", ...] +``` + +### 3. Send Your First Task + +```javascript +const response = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [{ + data: { + skill: "get_products", + input: { + buying_mode: "brief", + brief: "Find video products for a 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) + +### Profile Invocation Messages + +An activated profile invocation contains exactly one DataPart with `{ skill, input }`. A message with more than one DataPart, of any shape, MUST be rejected, not resolved by picking the first or last (see the [profile specification](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension#invocation-message)). The response-side "last DataPart" rule never applies to inbound invocation messages. Advisory TextParts are allowed and never override the structured input: + +```javascript +// SDK-generated display label + authoritative structured invocation +const response = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [ + { + text: "AdCP task: create_media_buy" + }, + { + data: { + skill: "create_media_buy", + input: { + idempotency_key: "550e8400-e29b-41d4-a716-446655440000", + account: { account_id: "acc_demo_001" }, + brand: { domain: "brand.example" }, + proposal_id: "proposal_001", + total_budget: { amount: 100000, currency: "USD" }, + start_time: "asap", + end_time: "2027-06-30T23:59:59Z" + } + } + } + ] + } +}); +``` + +### Skill Invocation Methods + +#### Natural Language (Separate Interface) + +The Agent Card used above marks the AdCP profile `required: true`, so a +text-only request is invalid on that interface. An agent that also supports +generic conversation publishes a separate Agent Card/interface without the +required profile. Context correlation does not turn text into typed AdCP input. + +#### Explicit Skill (Deterministic) +```javascript +// Explicit skill with an exact task request +const task = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [{ + data: { + skill: "get_products", + input: { + buying_mode: "brief", + brief: "Premium CTV inventory under $50 CPM" + } + } + }] + } +}); +``` + +#### Structured Invocation with Generated Display Text +```javascript +// Display text contains no information beyond the structured invocation +const task = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [ + { + text: "AdCP task: get_products" + }, + { + data: { + skill: "get_products", + input: { + buying_mode: "brief", + brief: "Premium CTV inventory for a spring campaign under $45 CPM" + } + } + } + ] + } +}); +``` + +The profile rejects `parameters` as an alias and rejects ambiguous messages with multiple invocation DataParts. SDKs should omit TextParts by default or generate a display-only label; implementers should never put instructions there. File and resource references belong in fields defined by the selected AdCP request schema. See the [profile specification](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension#invocation-message) for the normative rules. + +**Status Handling**: See [Task Lifecycle](/dist/docs/3.3.0-beta.3/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. + +### Normalized SDK 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. + +The following is the normalized `@adcp/sdk` client shape. It flattens raw A2A +`Task.status.state` to lowercase `status` and exposes the A2A Task `id` as +`taskId`; it is not the raw A2A 1.0 wire object. + +```json +{ + "status": "completed", // Normalized A2A transport status + "taskId": "task-123", // Raw A2A Task.id + "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": { + "status": "completed", + "cache_scope": "account", + "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.3.0-beta.3/building/by-layer/L0/a2a-response-format).** + +### Normalized A2A Fields +- **taskId**: A2A Task `id`, renamed by the SDK adapter +- **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.3.0-beta.3/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?.filter(isData).at(-1)?.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.3.0-beta.3/building/by-layer/L0/a2a-response-format).** + +## Push Notifications and AdCP Webhooks + +A2A transport notifications and AdCP application webhooks have different +lifetimes. `configuration.taskPushNotificationConfig` asks A2A to deliver +updates for the current A2A Task. It does not track a durable AdCP operation +after that Task completes. For an AdCP Submitted result, put +`push_notification_config` inside the task's typed `input`; its +`operation_id` is the durable webhook correlation key. + +**Durable AdCP webhook:** + +```javascript +const operationId = crypto.randomUUID(); + +await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [{ + data: { + skill: "create_media_buy", + input: { + idempotency_key: crypto.randomUUID(), + account: { account_id: "acc_demo_001" }, + brand: { domain: "brand.example" }, + proposal_id: "proposal_001", + total_budget: { amount: 100000, currency: "USD" }, + start_time: "asap", + end_time: "2027-06-30T23:59:59Z", + push_notification_config: { + url: "https://buyer.example/webhooks/adcp", + operation_id: operationId + } + } + } + }] + } +}); +``` + +When transport-level delivery is useful while the handler is still running, +pass the separate A2A 1.0 configuration: + +```javascript +configuration: { + taskPushNotificationConfig: { + id: crypto.randomUUID(), + url: "https://buyer.example/webhooks/a2a", + token: "client-validation-token" + } +} +``` + +An initial `SendMessage` request does not set `taskId` in that configuration; +the A2A server assigns the transport Task ID. A2A's configuration has no +AdCP `operation_id` field. + +For webhook payload formats, protocol comparison, and detailed handling examples, see [Webhooks](/dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks). + +## SSE Streaming (A2A-Specific) + +A2A streaming uses the protocol operations `SendStreamingMessage` and +`SubscribeToTask`, whose SSE events each contain a `StreamResponse` branch. +Clients must send the same authentication, `A2A-Version`, and `A2A-Extensions` +service parameters required by non-streaming calls. A bare browser +`EventSource` cannot set those headers and is not a portable profile client. + +The minimal helper earlier in this guide implements non-streaming +`SendMessage`. With the default `returnImmediately: false`, that operation +waits for a terminal or interrupted A2A state. A streaming-capable adapter +should expose the official streaming operations and parse `{ task }`, +`{ statusUpdate }`, `{ artifactUpdate }`, and `{ message }` frames. If an AdCP +handler ultimately returns `status: "submitted"`, the final A2A Task is still +completed; poll the durable operation with fresh typed `get_task_status` +invocations. + +### 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": [{ + "artifactId": "media-buy-result", + "name": "task_result", + "parts": [ + { + "text": "Media buy created successfully" + }, + { + "data": { + "status": "completed", + "media_buy_id": "mb_12345", + "confirmed_at": "2026-01-22T10:30:00.000Z", + "revision": 1, + "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 the native A2A Get Task operation 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": { + "messageId": "msg-input-required-001", + "taskId": "task_456", + "contextId": "ctx_123", + "role": "ROLE_AGENT", + "parts": [ + { "text": "Campaign budget $150K requires VP approval" }, + { + "data": { + "reason": "BUDGET_EXCEEDS_LIMIT" + } + } + ] + }, + "timestamp": "2026-01-22T10:15:00.000Z" + } + } +} +``` + +**Do not conflate the two `submitted` values.** Native A2A `TASK_STATE_SUBMITTED` is an interim transport state and may appear in a `TaskStatusUpdateEvent` before an AdCP handler returns. An AdCP response whose DataPart contains `status: "submitted"` is instead pinned inside an A2A `TASK_STATE_COMPLETED` Task by the AdCP v3 profile. Observe that durable AdCP operation with `get_task_status`. + +### 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` | Native A2A transitions during handler execution (`working`, `input-required`, `auth-required`, `submitted`) | Lightweight transport status 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 (for example, native A2A Get Task) 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. **`configuration.taskPushNotificationConfig` is provided** in the request +3. **The A2A transport task runs asynchronously** — initial A2A state is `working` or native `submitted` + +If the initial response is already terminal (`completed`, `failed`, `rejected`), no webhook is sent—you already have the result. + +An AdCP `status: "submitted"` DataPart does not keep the A2A Task open and does not turn A2A push notifications into AdCP-operation notifications. The profile returns it inside a completed A2A Task; use `get_task_status` (or an AdCP webhook explicitly defined by the task schema) for the durable operation. + +**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) | +| native A2A `submitted` | Transport status payload | Handler has not yet returned an AdCP response | + +Schema reference: [`async-response-data.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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?.filter(isDataPart).at(-1) + ?? 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) + +A2A assigns `contextId` on the first exchange. A client continues that context +by placing the returned value inside the next `Message`. The typed invocation +remains complete and authoritative on every turn. + +```javascript +// First request: the server assigns a contextId. +const response1 = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [{ data: { + skill: "get_products", + input: { buying_mode: "brief", brief: "Find premium video products" } + }}] + } +}); + +// Follow-up: contextId is a Message field, not a sibling of message. +const response2 = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + contextId: response1.contextId, + role: "ROLE_USER", + parts: [{ data: { + skill: "get_products", + input: { buying_mode: "brief", brief: "Premium sports video products" } + }}] + } +}); +``` + +## File and Multi-Modal Inputs + +Generic A2A messages can combine text, data, and files. The activated AdCP v3 A2A profile deliberately narrows invocation messages to one structured DataPart plus optional advisory TextParts. Put resource references in the selected AdCP task's typed `input`; do not add a FilePart that the task schema cannot validate. + +### Creative Upload with Context + +```javascript +// The asset URL is part of the typed AdCP request. +const response = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [ + { + text: "AdCP task: sync_creatives" + }, + { + data: { + skill: "sync_creatives", + input: { + idempotency_key: crypto.randomUUID(), + account: { account_id: "acc_demo_001" }, + creatives: [{ + creative_id: "cr_hero_30s", + name: "Sports hero 30s", + format_kind: "video_hosted", + assets: { + video_main: { + asset_type: "video", + url: "https://cdn.example.com/hero-30s.mp4", + mime_type: "video/mp4", + duration_ms: 30000, + width: 1920, + height: 1080 + } + } + }] + } + } + } + ] + } +}); +``` + +If a task schema does not define a field for the file or resource, that input is not supported by the AdCP v3 A2A profile. Use a separate generic A2A interface or first transform the resource into schema-valid AdCP fields. + +## 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.3.0-beta.3/building/by-layer/L3/webhooks). + +### Skill Structure +```javascript +// Standard pattern for explicit skill invocation +await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [{ + data: { + skill: "skill_id", // Exact AgentSkill.id from Agent Card + input: { // Task-specific request + // See task documentation for request fields + } + } + }] + } +}); +``` + +### Available Skills +- **Protocol**: `get_adcp_capabilities` (start here to discover agent capabilities) +- **Media Buy**: `get_products`, `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.3.0-beta.3/media-buy) and [Signals](/dist/docs/3.3.0-beta.3/signals/overview) documentation for complete parameter specifications. + +## Agent Cards + +A2A 1.0 agents advertise capabilities via Agent Cards at `.well-known/agent-card.json`. + +### Discovering Agent Cards +```javascript +// Get agent capabilities +const agentCard = await a2a.getAgentCard(); + +// List available skills +const skillIds = agentCard.skills.map(skill => skill.id); +console.log('Available skill IDs:', skillIds); + +// Get skill details +const getProductsSkill = agentCard.skills.find(s => s.id === '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": { + "httpAuthSecurityScheme": { + "scheme": "Bearer", + "bearerFormat": "JWT" + } + } + }, + "securityRequirements": [{ + "schemes": { "bearerAuth": { "list": [] } } + }], + "supportedInterfaces": [ + { + "url": "https://sales.example.com/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "defaultInputModes": ["application/json"], + "defaultOutputModes": ["application/json"], + "capabilities": { + "streaming": true, + "pushNotifications": true, + "extendedAgentCard": false, + "extensions": [ + { + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "description": "AdCP structured task invocation profile", + "required": true + } + ] + }, + "skills": [ + { + "id": "get_adcp_capabilities", + "name": "Discover AdCP capabilities", + "description": "Discover runtime AdCP versions, protocols, and features", + "tags": ["adcp"] + }, + { + "id": "get_products", + "name": "Discover advertising products", + "description": "Discover available advertising products", + "tags": ["adcp", "media-buy"], + "examples": [ + "Find premium CTV inventory for sports fans", + "Show me video products under $50 CPM" + ] + } + ] +} +``` + +### 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 + + +Use [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) for runtime capability discovery. The Agent Card extension declaration identifies the A2A wire profile only. + + +Include the versioned AdCP profile under `capabilities.extensions[]` and activate it on every invocation with `A2A-Extensions: https://adcontextprotocol.org/extensions/adcp/v3`. + +The A2A protocol's `AgentExtension` has: +- **`uri`**: Extension identifier (`https://adcontextprotocol.org/extensions/adcp/v3`) +- **`description`**: Human-readable description of how you use AdCP +- **`required`**: `true` on an interface that requires the structured AdCP profile +- **`params`**: Omitted or empty for this profile + +```javascript +// Check if agent supports AdCP +const agentCard = await fetch('https://sales.example.com/.well-known/agent-card.json') + .then(r => r.json()); + +// Find the AdCP profile under AgentCapabilities. +const adcpExt = agentCard.capabilities.extensions?.find( + ext => ext.uri === 'https://adcontextprotocol.org/extensions/adcp/v3' +); + +if (adcpExt) { + // Activate the profile, then call the runtime discovery task. + const capabilities = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: 'ROLE_USER', + parts: [{ data: { skill: 'get_adcp_capabilities', input: {} } }] + } + }); + console.log(capabilities); +} +``` + +The profile forbids copying AdCP versions, supported domains, or feature flags into extension params. Those values change at runtime and remain authoritative only in `get_adcp_capabilities`. The unversioned v2 `adcp-extension.json` capability payload is not part of this profile. + +:::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.3.0-beta.3/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.3.0-beta.3/reference/versioning#version-negotiation) for the full contract. +::: + +**Benefits**: +- Clients can negotiate one versioned, deterministic AdCP message shape +- Runtime capability discovery has one authority: `get_adcp_capabilities` +- Breaking profile changes negotiate through a new extension URI rather than ambiguous params + +## Integration Example + +```javascript +// Use unified status handling (see Core Concepts) +async function handleA2aResponse(response, invocation) { + switch (response.status) { + case 'input-required': + // Collect fields and rebuild a complete, schema-valid typed request. + const refinedInput = await collectTypedInput(invocation.input, response.message); + return a2a.send({ + message: { + messageId: crypto.randomUUID(), + taskId: response.taskId, + contextId: response.contextId, + role: "ROLE_USER", + parts: [{ data: { skill: invocation.skill, input: refinedInput } }] + } + }); + + 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: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [ + { text: "AdCP task: get_products" }, + { + data: { + skill: "get_products", + input: { + idempotency_key: "550e8400-e29b-41d4-a716-446655442071", + buying_mode: "brief", + brief: "Luxury car inventory for in-market shoppers" + } + } + } + ] + } +}); + +const finalResult = await handleA2aResponse(result, { + skill: "get_products", + input: { + idempotency_key: "550e8400-e29b-41d4-a716-446655442071", + buying_mode: "brief", + brief: "Luxury car inventory for in-market shoppers" + } +}); +``` + +## 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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/by-layer/L3/task-lifecycle) for status handling and workflows +- **Task Reference**: See [Media Buy Tasks](/dist/docs/3.3.0-beta.3/media-buy) and [Signals](/dist/docs/3.3.0-beta.3/signals/overview) +- **Protocol Comparison**: Compare with [MCP integration](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/by-layer/L3/task-lifecycle) - this guide focuses on A2A transport specifics only.** diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension.mdx new file mode 100644 index 00000000000..d45da51021b --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension.mdx @@ -0,0 +1,318 @@ +--- +title: AdCP A2A Profile Extension v3 +sidebarTitle: A2A profile extension +description: "Normative A2A 1.0 profile for invoking AdCP tasks with versioned extension negotiation, structured DataParts, and AdCP-level async polling." +"og:title": "AdCP — A2A Profile Extension v3" +testable: false +--- + +# AdCP A2A Profile Extension v3 + +This document defines the normative AdCP profile for [A2A 1.0](https://a2a-protocol.org/latest/specification/). It narrows A2A messages and task results enough for independent AdCP clients and agents to interoperate without defining a new transport. + +## Extension identifier + +The extension URI is: + +```text +https://adcontextprotocol.org/extensions/adcp/v3 +``` + +The `extensions/adcp` path scopes this identifier to the AdCP profile registered +through A2A's extension mechanism. It is an extension identifier and normative +document URL, not a JSON Schema URL, so it does not live under `/schemas/3.3.0-beta.3/`. + +The URI is versioned by the compatible AdCP major: `/v3` identifies the A2A +profile for AdCP 3.x. The negotiated AdCP release still selects the exact task +schemas—for example, 3.0, 3.1, or 3.2 schemas under `/schemas/3.3.0-beta.3/`—without +requiring a new extension identity for every additive minor release. AdCP 4.x +will use `/extensions/adcp/v4`. This profile has no extension dependencies +beyond A2A 1.0. + +## Advertisement and activation + +An A2A interface that implements this profile MUST advertise the extension under `AgentCard.capabilities.extensions[]`: + +```json +{ + "supportedInterfaces": [ + { + "url": "https://sales.example.com/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + } + ], + "capabilities": { + "extensions": [ + { + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "description": "AdCP structured task invocation profile", + "required": true + } + ] + } +} +``` + +The `AgentExtension.params` member MUST be omitted or empty. It MUST NOT contain AdCP versions, protocol domains, feature flags, or other runtime capabilities. Clients activate the profile on every request using the A2A service parameter: + +```http +A2A-Version: 1.0 +A2A-Extensions: https://adcontextprotocol.org/extensions/adcp/v3 +``` + +For HTTP-based bindings, service parameters are HTTP headers. The response SHOULD echo the URI in `A2A-Extensions` to confirm activation, as specified by A2A 1.0. An agent that declares the profile `required: true` MUST reject a request that does not activate it; it MUST NOT guess that an unactivated DataPart is an AdCP invocation. + +## SDK and adapter behavior + +This profile is designed to be implemented once in an A2A transport adapter, +not separately in every AdCP task handler. Extension support remains explicit +opt-in, as required by A2A. Once a developer enables this profile, an SDK or +adapter that claims support for it MUST automate the profile mechanics: + +- on the server, advertise the extension and verify request activation; +- on the client, discover the declaration and activate the extension; +- encode and decode `{ skill, input }` DataParts; +- validate `input` against the selected task request schema and dispatch the + named AdCP handler; +- map the handler's direct response into the A2A Task artifact; and +- expose AdCP Submitted results by their `task_id`, with + `get_task_status` as the polling operation. + +Application code should register the same typed AdCP handler it would expose +over another transport. It should not parse A2A Parts, author prompt text, or +reimplement the mapping rules. Raw A2A clients can implement the wire rules +directly, but SDKs SHOULD make the normal integration path work from typed task +inputs and outputs alone. + +## Runtime discovery + +The Agent Card declaration says only that the interface supports this wire profile. Runtime AdCP discovery remains the `get_adcp_capabilities` task. + +After activating the profile, a client SHOULD invoke `get_adcp_capabilities` before other AdCP tasks. Its response remains the source of truth for supported AdCP versions, protocol domains, feature flags, and seller capabilities. Agents MUST NOT duplicate that information in `AgentExtension.params`; duplicate static metadata becomes stale and creates two competing authorities. + +Agent Card `skills[]` entries identify the AdCP tasks dispatchable on that interface. For every AdCP task, `AgentSkill.id` MUST equal the exact AdCP task name; `AgentSkill.name` is a human-readable label and need not equal the task name. The invocation's `skill` member references `AgentSkill.id`, never `AgentSkill.name`. The `get_adcp_capabilities` ID MUST appear on every interface that implements this profile. An agent that can return an AdCP Submitted response under this profile MUST also advertise the `get_task_status` ID. + +## Invocation message + +An activated invocation Message MUST contain exactly one DataPart whose `data` value has this shape: + +```json +{ + "skill": "get_products", + "input": { + "buying_mode": "brief", + "brief": "Premium CTV inventory for a spring campaign" + } +} +``` + +The invocation object has exactly two members: + +| Member | Requirement | +|---|---| +| `skill` | Required non-empty string. The exact `AgentSkill.id`, which for this profile is the AdCP task name, such as `get_products` or `create_media_buy`. | +| `input` | Required object. The task request, validated unchanged against that task's AdCP request schema. | + +`parameters` is not an alias for `input` in this profile. A client that activates v3 and sends `parameters`, omits `input`, or sends more than one invocation DataPart is non-conformant. The agent MUST reject the invocation rather than choose among ambiguous inputs. + +### JSON number interoperability + +A2A 1.0 models `Part.data` as `google.protobuf.Value`. Its numeric arm is an IEEE 754 double, so an integer-valued JSON number can reserialize with a decimal point (`3` → `3.0`). AdCP treats those forms as the same JSON numeric value: both satisfy a JSON Schema `integer` type because they have no fractional value. This lexical change does not violate the [context echo contract](/dist/docs/3.3.0-beta.3/building/by-layer/L2/context-sessions#normative-echo-contract) and does not make an otherwise valid integer-typed task field invalid. + +That equivalence does not permit numeric rounding. Before A2A encoding, a sender MUST reject an integer-valued JSON number outside `[-9007199254740991, 9007199254740991]`; it MUST NOT round, truncate, or transmit that value through a DataPart number. A receiver MUST reject a decoded integer-valued DataPart number outside that range. A receiver with access to the raw JSON binding MUST also reject an out-of-range integer token before protobuf decoding. Schema authors MUST use a string representation for exact integer identifiers or counters that can exceed the range, and callers MUST use strings for such values in opaque `context`. + +Applications that require exact decimal digits beyond binary64 precision MUST likewise use a schema-defined string representation; callers carrying such decimals in opaque `context` MUST encode them as strings. Within the supported numeric domain, receivers and verifiers MUST compare the post-A2A decoded numeric values rather than their serialized spelling. `3` and `3.0` therefore match, while a value changed by binary64 rounding does not. + +The Message MAY also contain TextParts solely for display, accessibility, or +logging. Senders SHOULD omit them by default. An SDK MAY synthesize a short +label from the structured invocation, but the text MUST NOT introduce any +instruction or fact that is absent from `skill` and `input`. Implementations +MUST NOT ask a human integrator to author this text, and receivers MUST NOT +merge it into `input`, pass it to the task handler as an instruction, or use it +to override structured data. FileParts and additional DataParts are outside +this v3 invocation profile. References to files or other resources belong in +fields defined by the selected AdCP request schema. + +```json +{ + "messageId": "msg-get-products-001", + "role": "ROLE_USER", + "parts": [ + { + "text": "AdCP task: get_products" + }, + { + "data": { + "skill": "get_products", + "input": { + "buying_mode": "brief", + "brief": "Premium CTV inventory for a spring campaign" + } + } + } + ] +} +``` + +If the A2A Task enters `TASK_STATE_INPUT_REQUIRED` before the AdCP handler +returns, a continuation MUST carry a new `messageId` and the existing A2A +`taskId` and `contextId` inside the Message. Its Parts still follow this +profile: exactly one authoritative `{ skill, input }` DataPart, with a complete +schema-valid task request. The continued Task keeps the same A2A Task ID. + +Those native A2A identifiers are the verified continuation descriptor for this +profile. An AdCP `task_id` returned by the task payload is a separate polling +handle and MUST NOT be substituted for a missing A2A `taskId` or `contextId`. +Nested continuations and cross-channel terminal races follow the [async +identity and convergence +contract](/dist/docs/3.3.0-beta.3/building/by-layer/L3/async-identity-and-convergence). + +## Response mapping + +When the AdCP handler returns a schema-valid task response, the A2A invocation is complete. A non-streaming A2A `SendMessage` response MUST select its `task` branch. That Task has `status.state` `TASK_STATE_COMPLETED`, and its artifact contains the direct AdCP response in a DataPart: + +```json +{ + "task": { + "id": "a2a-task-7f8c", + "contextId": "ctx-products-7f8c", + "status": { + "state": "TASK_STATE_COMPLETED" + }, + "artifacts": [ + { + "artifactId": "adcp-result", + "parts": [ + { + "data": { + "status": "completed", + "cache_scope": "account", + "products": [] + } + } + ] + } + ] + } +} +``` + +`artifactId` is the ordinary A2A identifier for that artifact. It is opaque, +needs to be unique only within the A2A Task, and does not identify an AdCP +schema or task type; `adcp-result` above is merely an example value. There is +no single generic AdCP result schema. The invoked `skill` selects the +task-specific response schema, and the DataPart MUST contain that response +directly, without a `{ "response": ... }` framework wrapper. A response +artifact MAY also contain advisory TextParts. Clients use the +[A2A response extraction algorithm](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction) +to select the authoritative DataPart. + +Profile-routing errors that prevent handler invocation use native A2A error handling. A fatal failure after task execution starts may use an A2A failed or rejected Task with a structured `adcp_error` DataPart. A schema-valid AdCP business outcome—including a task-specific rejection or partial result with `errors[]`—is still a completed A2A invocation. + +## Submitted AdCP work + +A2A task state and AdCP task state are separate state machines. If an AdCP handler returns a Submitted response, the A2A invocation has still completed: + +```json +{ + "task": { + "id": "a2a-task-create-42", + "contextId": "ctx-create-42", + "status": { + "state": "TASK_STATE_COMPLETED" + }, + "artifacts": [ + { + "artifactId": "adcp-result", + "parts": [ + { + "text": "The media buy is awaiting IO signature." + }, + { + "data": { + "status": "submitted", + "task_id": "adcp-task-9a21", + "message": "Awaiting IO signature" + } + } + ] + } + ] + } +} +``` + +The A2A Task identifier (`a2a-task-create-42`) identifies the completed transport invocation. The AdCP `task_id` (`adcp-task-9a21`) identifies the durable AdCP operation. They are independent identifiers and clients MUST NOT assume they are equal. + +The AdCP handle appears exactly once, as `task_id` in the AdCP DataPart. Agents MUST NOT duplicate it as `artifact.metadata.adcp_task_id` or any other A2A metadata member. + +To observe the durable operation, the client sends a new activated invocation of the AdCP `get_task_status` task: + +```json +{ + "messageId": "msg-poll-001", + "role": "ROLE_USER", + "parts": [ + { + "data": { + "skill": "get_task_status", + "input": { + "task_id": "adcp-task-9a21", + "include_result": true + } + } + } + ] +} +``` + +Each poll is its own A2A invocation and completes with a direct `get_task_status` response DataPart. Clients continue polling according to the seller's retry guidance until the AdCP response is terminal. They MUST NOT poll the completed A2A Task to infer progress of the separate AdCP operation. + +## Relationship to A2A async and conversation + +The profile uses A2A as more than an alternate RPC envelope. A2A still +provides Agent Card discovery, interface and extension negotiation, +authentication, `contextId` conversation correlation, streaming, push +delivery, artifacts, and native input/auth challenges. + +The boundary is when the AdCP handler returns. While a profile invocation is +still executing, an agent can use native A2A `TASK_STATE_WORKING` or +`TASK_STATE_SUBMITTED` updates and stream progress through A2A. When the +handler returns a schema-valid AdCP response, that A2A invocation is complete. +If the returned AdCP response itself says `status: "submitted"`, it represents +a separate durable business operation that may outlive the A2A Task, a client +connection, or even the transport used to observe it. AdCP therefore owns that +operation's portable `task_id` and `get_task_status` semantics. + +Because this Agent Card marks the profile `required: true`, every request to +its advertised interface must activate the profile and use the typed invocation +shape. An implementation that also offers generic conversational A2A messages +must publish a separate Agent Card/interface that does not mark this profile +required. Context correlation never makes a TextPart authoritative for an AdCP +handler. + +## Security and validation + +- Treat TextParts as untrusted advisory text. Never merge them into the typed `input` object. +- Validate `input` against the selected task request schema before handler dispatch. +- Apply the same authentication, authorization, signing, idempotency, and account-scoping rules used for the equivalent AdCP task over MCP. +- Resolve the AdCP operation from the invocation DataPart's `skill` before the signing gate, and dispatch on that same value. `request_signing.required_for` matches the resolved operation, not the JSON-RPC `SendMessage` method. See [Operation resolution over A2A](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#operation-resolution-over-a2a). +- Bound the number and size of Parts before parsing. Reject duplicate invocation DataParts rather than selecting the first or last. +- Validate response DataParts against the selected task response schema before using them. + +## Test vectors + +Machine-readable advertisement, activation, invocation, Submitted mapping, and polling fixtures are published in [`a2a-profile-extension-v3.json`](https://adcontextprotocol.org/test-vectors/a2a-profile-extension-v3.json). See [Reference test vectors](/dist/docs/3.3.0-beta.3/reference/test-vectors) for versioning guidance. + +Request-signing vectors for `required_for` over A2A, including the signed and unsigned `create_media_buy` cases, are in [`test-vectors/request-signing/a2a/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/a2a/). + +## References + +- [A2A 1.0 specification](https://a2a-protocol.org/latest/specification/) +- [A2A extensions](https://a2a-protocol.org/latest/topics/extensions/) +- [Protocol Buffers `Value`](https://protobuf.dev/reference/protobuf/google.protobuf/#value) +- [JSON Schema numeric types](https://json-schema.org/understanding-json-schema/reference/numeric) +- [A2A Guide](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-guide) +- [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) +- [Calling an AdCP agent](/dist/docs/3.3.0-beta.3/protocol/calling-an-agent) diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction.mdx new file mode 100644 index 00000000000..2cefda74047 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction.mdx @@ -0,0 +1,293 @@ +--- +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.3.0-beta.3/building/by-layer/L0/a2a-response-format). For error-specific extraction, see [Transport Error Mapping](/dist/docs/3.3.0-beta.3/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. This rule governs response extraction only; it does not apply to inbound invocation messages (see [Last-DataPart Authority](#last-datapart-authority)). +- **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. +- **Two status layers.** `task.status.state` is the A2A transport lifecycle; the extracted DataPart is the complete AdCP response and carries its own `status`. They normally align, but a structured AdCP business rejection uses A2A `completed` with DataPart `status: "rejected"` because the transport call succeeded. +- **Submitted profile mapping.** Under the [AdCP A2A Profile Extension v3](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension), an AdCP DataPart with `status: "submitted"` is carried by an A2A `TASK_STATE_COMPLETED` Task and extracted from its artifact. Native A2A `TASK_STATE_SUBMITTED` remains an interim transport state used only before the AdCP handler has returned. + +## 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. + +**Response envelopes.** A2A 1.0 non-streaming `SendMessage` responses select +one branch of `SendMessageResponse`, normally `{ "task": { ... } }` for this +profile. Streaming responses and push-notification payloads use a +`StreamResponse` oneof with exactly one of `task`, `message`, `statusUpdate`, or +`artifactUpdate` (A2A 1.0 §3.2.3, §4.3.3). Native `GetTask` and SDKs that +explicitly unwrap `SendMessageResponse` may deliver a bare Task. Extraction +unwraps a supported 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 transport rejection (1.0) | `.artifacts[0].parts[]` | Last DataPart (normally carries `adcp_error`) | +| `working` | Interim | `status.message.parts[]` | First DataPart | +| native A2A `submitted` | Interim transport state | `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. Native A2A `rejected` tasks are transport rejections and are expected to carry an `adcp_error` DataPart describing why the call was rejected (tier/policy/validation). + +An AdCP structured business rejection is different. The seller successfully handled the call and produced a typed result, so A2A uses `task.status.state: "completed"`; the authoritative DataPart carries `status: "rejected"`, `reason`, and any task-specific fields. The same rule applies to an AdCP Submitted response: the A2A state is `completed`, while the authoritative DataPart carries `status: "submitted"` and `task_id`. Extractors MUST return the DataPart unchanged and MUST NOT overwrite its `status` with the A2A transport state. + +## Extraction Algorithm + +Clients MUST extract AdCP data from A2A responses using these steps: + +0. **Unwrap A2A response 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. This covers the A2A 1.0 `SendMessageResponse` task/message branches and `StreamResponse` oneof. Bare `Task` / `TaskStatusUpdateEvent` objects from `GetTask`, an explicitly unwrapping SDK, 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 are valid out-of-band agent messages and MUST be ignored by task-oriented extractors — step 1 returns null when the unwrapped object has no `status.state`. After authentication and envelope validation, a push receiver SHOULD still acknowledge a recognized `{ message }` delivery with 2xx even when it does not mutate task state. Malformed or unauthenticated deliveries remain transport errors. +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). This state selects the extraction location; it does not replace a `status` field present in the extracted AdCP DataPart. +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. This rule applies to **responses** only. It does not apply to inbound invocation messages: one carrying more than one DataPart of any shape MUST be rejected, never resolved by taking the last. See the [profile specification](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension#invocation-message). + +```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.3.0-beta.3/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.3.0-beta.3/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 before schema validation: no single part exceeds the configured call limit. Unlike error payloads (capped at 4096 bytes), success payloads can be larger but still need bounds. + +The per-part bound is the call limit rather than a tighter figure because of the [single-artifact invariant](#adcp-conventions-on-top-of-a2a) above. An AdCP task produces one artifact, and the last DataPart in it is the authoritative response — so the authoritative part *is* the whole payload, and a tighter per-part number would silently become the effective whole-call cap. That would reject over A2A a response the byte-identical MCP call accepts, which is the transport divergence this bound exists to prevent. Earlier DataParts are superseded progress snapshots; a client MAY bound those more tightly, since no authoritative payload rides in them. + +A per-part bound alone is not sufficient. Clients SHOULD apply the general obligation to [bound every agent response](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#response-size-bounds) to the A2A call path as a whole: enforce the limit while the body streams rather than after the `Task` is buffered or parsed, pre-reject an over-limit `Content-Length`, bound raw and decoded bytes, pair the cap with a deadline, bound JSON depth and node count after the cap passes, and fail with a distinct error instead of truncating. The whole-call default is **4 MiB (4,194,304 bytes)**, matching the MCP profile and the protocol-wide ceiling on a default. The per-part check above is the same limit applied one level down, so it catches a part that alone exceeds the bound before schema validation; it is not a second, tighter number. The limit SHOULD be configurable per agent and per call; a client that needs more from a known counterparty raises it for that agent or that call rather than shipping a larger default. + +`SendStreamingMessage` and `SubscribeToTask` deliver `StreamResponse` frames over `text/event-stream`, so the bound applies both to each SSE event and to the cumulative bytes the client **retains** for one task — otherwise a peer escapes it by spreading an oversized set of artifacts across many `artifactUpdate` frames. Because clients accumulate `artifactUpdate` chunks until the terminal `{ task }` frame arrives, the cumulative count is scoped to that accumulation, not to a TCP connection: it carries across stream reconnects and `SubscribeToTask` resumption rather than resetting, and interim `statusUpdate` progress snapshots, which are not accumulated, count only against the per-event bound. Scoping the counter to retained bytes is what lets a long-running task emit many progress frames without either tripping the bound or reopening the bypass. + +### 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`. Response extraction only; this does not apply to inbound invocation messages. +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.3.0-beta.3/building/by-layer/L0/a2a-response-format) — canonical response structure for sellers +- [Transport Error Mapping](/dist/docs/3.3.0-beta.3/building/operating/transport-errors) — error extraction from MCP and A2A +- [MCP Response Extraction](/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction) — equivalent spec for MCP +- [A2A Guide](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-guide) — A2A transport integration diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-format.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-format.mdx new file mode 100644 index 00000000000..cb563eb1cfe --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-format.mdx @@ -0,0 +1,685 @@ +--- +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.3.0-beta.3/building/by-layer/L0/a2a-guide#a2a-protocol-versions) for a side-by-side with v0.3. + +There are two distinct status layers. A2A `task.status.state` describes transport execution; the AdCP DataPart's top-level `status` describes the task-specific protocol result. They usually align, but they are not aliases. For example, a structured `get_products` business rejection uses A2A `TASK_STATE_COMPLETED` and DataPart `status: "rejected"`: the invocation completed and produced a deliberate commercial-refusal result. + +The [AdCP A2A Profile Extension v3](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension) also pins an AdCP `status: "submitted"` response inside an A2A `TASK_STATE_COMPLETED` Task. Native A2A `TASK_STATE_SUBMITTED` is an interim transport state before the AdCP handler returns; it is not the mapping for queued AdCP work. + +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 (responses only; does not apply to inbound invocation messages) +- NOT wrap AdCP payloads in custom framework objects (no `{ response: {...} }` wrappers) + +**Recommended non-streaming `SendMessageResponse` pattern:** + +```json +{ + "task": { + "id": "task_123", + "contextId": "ctx_456", + "status": { + "state": "TASK_STATE_COMPLETED" + }, + "artifacts": [{ + "artifactId": "task-result", + "name": "task_result", + "parts": [ + { + "text": "Found 12 video products perfect for pet food campaigns" + }, + { + "data": { + "status": "completed", + "cache_scope": "account", + "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) + +AdCP profile responses use exactly one artifact. Put related TextParts, +DataParts, and FileParts in that artifact; model fundamentally separate +deliverables as separate AdCP tasks. + +### AdCP Submitted Responses (A2A completed) + +When the AdCP handler returns `status: "submitted"`, the handler invocation is finished even though the durable AdCP operation is queued. The profile therefore uses an A2A completed Task and carries the Submitted response in the artifact: + +```json +{ + "task": { + "id": "a2a-task-create-42", + "contextId": "ctx-create-42", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{ + "data": { + "status": "submitted", + "task_id": "adcp-task-9a21", + "message": "Awaiting IO signature" + } + }] + }] + } +} +``` + +The A2A Task id and AdCP `task_id` are independent. The AdCP handle MUST remain only in the direct DataPart; do not copy it to `artifact.metadata.adcp_task_id`. Poll by sending a new profile invocation with `skill: "get_task_status"` and `input.task_id: "adcp-task-9a21"`, not by polling the completed A2A Task. + +### Interim A2A Responses (working, native 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": { + "messageId": "msg-progress-001", + "taskId": "task_123", + "contextId": "ctx_456", + "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 such as native A2A Get Task deliver the bare object. Clients unwrap before reading `status.state` — see [A2A Response Extraction](/dist/docs/3.3.0-beta.3/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 a final A2A transport state is reached** (`completed`, `failed`, `canceled`, or native A2A `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": { + "status": "completed", + "cache_scope": "account", + "products": [] + } +} +``` + +**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? }` | +| native A2A `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 A2A transport state to determine the correct data extraction location. The raw wire value at `status.state` is `TASK_STATE_COMPLETED` in 1.0 or `completed` in v0.3. Normalize before comparing, extract the DataPart, and then branch separately on the AdCP payload's own `status`; never replace that payload status with the transport state. See [A2A Response Extraction](/dist/docs/3.3.0-beta.3/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; // normalized A2A transport state + + // 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; +} + +// Intentional asymmetry vs. extractDataPartFromArtifacts above: TextPart is +// presentational (first-match is fine — later TextParts are typically restatements, +// not corrections), while DataPart is authoritative and MUST use last-match to +// reflect the final streamed state. Do not "fix" this to be symmetric. +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({ + message: { + messageId: crypto.randomUUID(), + role: 'ROLE_USER', + parts: [{ data: { skill: taskName, input: 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? + +An artifact can accumulate multiple DataParts while streaming. Only the final +Task artifact uses last-DataPart authority: + +```json +{ + "task": { + "id": "task_123", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "product-result", + "parts": [ + {"text": "Found 12 products"}, + {"data": {"progress": 25}}, + {"data": {"status": "completed", "cache_scope": "account", "products": [...], "total": 12}} + ] + }] + } +} +``` + +**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: { + messageId: 'msg-progress-001', + taskId: 'task_123', + contextId: 'ctx_456', + 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 = { + id: 'task_123', + contextId: 'ctx_456', + status: { + state: 'TASK_STATE_COMPLETED', + timestamp: '2026-01-22T10:30:00.000Z' + }, + artifacts: [{ + artifactId: 'product-result', + parts: [ + { text: 'Found 3 products' }, + { data: { status: 'completed', cache_scope: 'account', 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 = { + id: 'task_123', + status: { state: 'TASK_STATE_COMPLETED' }, + artifacts: [{ + artifactId: 'product-result', + 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 + +A schema-valid business outcome, including a partial result with an `errors[]` +member, is still a completed A2A invocation. A non-streaming `SendMessage` +response selects the task branch: + +```json +{ + "task": { + "id": "task_123", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "signal-result", + "parts": [{ + "data": { + "status": "completed", + "signals": [], + "errors": [{ + "code": "NO_DATA_IN_REGION", + "message": "No signal data available for Australia" + }] + } + }] + }] + } +} +``` + +A fatal failure after execution starts uses a failed Task and carries structured +error data in its artifact. Pre-task routing failures use the A2A binding error +mechanism. + +```json +{ + "task": { + "id": "task_456", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_FAILED" }, + "artifacts": [{ + "artifactId": "adcp-error", + "parts": [{ + "data": { + "adcp_error": { + "code": "AUTHENTICATION_FAILED", + "message": "The API token is invalid or expired" + } + } + }] + }] + } +} +``` + +| Situation | A2A state | Data location | +|---|---|---| +| Schema-valid success, partial result, business rejection, or AdCP Submitted result | `TASK_STATE_COMPLETED` | `task.artifacts[0].parts[]` | +| Fatal failure after execution starts | `TASK_STATE_FAILED` | `task.artifacts[0].parts[]` | +| Native A2A policy rejection | `TASK_STATE_REJECTED` | `task.artifacts[0].parts[]` | +| Working, input-required, auth-required, or native submitted update | matching interim state | `statusUpdate.status.message.parts[]` | + +## Webhook Payloads + +A2A push notifications use a `StreamResponse` branch. Terminal delivery wraps +the same Task structure used by `SendMessageResponse`: + +```json +{ + "task": { + "id": "task_123", + "contextId": "ctx_456", + "status": { + "state": "TASK_STATE_COMPLETED", + "timestamp": "2026-01-22T10:30:00.000Z" + }, + "artifacts": [{ + "artifactId": "media-buy-result", + "parts": [ + { "text": "Media buy approved and live" }, + { "data": { "status": "completed", "media_buy_id": "mb_456", "confirmed_at": "2026-01-22T10:30:00.000Z", "revision": 1, "packages": [] } } + ] + }] + } +} +``` + +Interim delivery selects `statusUpdate`, and its server-authored Message carries +its own `messageId` plus the matching `taskId` and `contextId`: + +```json +{ + "statusUpdate": { + "taskId": "task_123", + "contextId": "ctx_456", + "status": { + "state": "TASK_STATE_INPUT_REQUIRED", + "timestamp": "2026-01-22T10:20:00.000Z", + "message": { + "messageId": "msg-input-001", + "taskId": "task_123", + "contextId": "ctx_456", + "role": "ROLE_AGENT", + "parts": [ + { "text": "Select a supported campaign start time." }, + { "data": { "reason": "START_TIME_REQUIRED" } } + ] + } + } + } +} +``` + +## File Parts in Responses + +File references may accompany the authoritative DataPart inside the Task +artifact. They never replace the typed AdCP response: + +```json +{ + "task": { + "id": "task_creative_789", + "contextId": "ctx_creative_789", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "creative-result", + "parts": [ + { "data": { "status": "completed", "creative_id": "cr_789" } }, + { "url": "https://cdn.example.com/cr_789/preview.mp4", "filename": "preview.mp4", "mediaType": "video/mp4" } + ] + }] + } +} +``` + +## Retry and Idempotency + +A continuation of an input-required A2A Task sends a new Message containing a +new `messageId`, the existing `taskId` and `contextId`, and a complete typed +profile invocation. Ordinary AdCP idempotency rules still apply to the task +input. Native `GetTask` observes an A2A transport Task; it does not replace +`get_task_status` for a durable AdCP operation. + +## 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 (responses only, not inbound invocation messages) +- [ ] **Never nest AdCP data in custom wrappers** (no `{ response: {...} }` objects) +- [ ] **DataPart content MUST match AdCP schemas** (validate against `[task]-response.json`) +- [ ] **Map an AdCP `status: "submitted"` response to A2A completed**, keep `task_id` only in the DataPart, and direct clients to the AdCP `get_task_status` task + +**Interim A2A Responses (status: "working", native "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.3.0-beta.3/building/by-layer/L0/a2a-guide) - Complete A2A integration guide +- [Task Lifecycle](/dist/docs/3.3.0-beta.3/building/by-layer/L3/task-lifecycle) - Status handling patterns +- [Error Handling](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling) - Fatal vs non-fatal errors +- [Protocol Comparison](/dist/docs/3.3.0-beta.3/building/concepts/protocol-comparison) - MCP vs A2A differences diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/index.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/index.mdx new file mode 100644 index 00000000000..50683446f72 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/cross-cutting/sdk-stack#l0--wire--transport). + +## Pages in this layer + +- **[Schemas](/dist/docs/3.3.0-beta.3/building/by-layer/L0/schemas)** — schema bundle, supply-chain verification, type generation, version pinning. +- **[MCP guide](/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-guide)** — `tools/call` envelope, JSON-RPC 2.0, transport adapter shape. +- **[A2A guide](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-guide)** — SSE event streams, task framing, artifact extraction. +- **[A2A response format](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-format)** — A2A wire-format reference. +- **[MCP response extraction](/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction)** — parsing `tools/call` responses to typed values. +- **[A2A response extraction](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction)** — parsing A2A streams and artifacts. diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-guide.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-guide.mdx new file mode 100644 index 00000000000..c460bf07196 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-guide.mdx @@ -0,0 +1,839 @@ +--- +title: MCP Guide +description: "AdCP MCP integration guide: tool call patterns, transport-session continuity, 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.3.0-beta.3/building/by-layer/L3/task-lifecycle). + +## Testing AdCP via MCP + +You can test AdCP tasks using the [CLI tools](/dist/docs/3.3.0-beta.3/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', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442072", + buying_mode: "brief", + 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', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442073", + buying_mode: "brief", + 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_capability_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`, `message`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `push_notification_config`, `governance_context`, `adcp_version`, and the deprecated `adcp_major_version`) 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.3.0-beta.3/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: compatibility metadata; not MCP session state + "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` maps to A2A's transport-native `contextId`. On MCP it is compatibility metadata only: callers MUST NOT rely on it for continuity, and servers ignore it where the selected request schema admits the field. It does not select or resume an MCP session. +- `context` is a **caller-supplied opaque echo object** ([`core/context.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/context.json)) — the agent preserves the same JSON value without interpreting it. 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.3.0-beta.3/building/by-layer/L3/task-lifecycle) for complete status handling patterns. + +**Status Handling**: See [Task Lifecycle](/dist/docs/3.3.0-beta.3/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 +// Read each returned product's canonical format_options[] for 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.3.0-beta.3/media-buy) and [Signals](/dist/docs/3.3.0-beta.3/signals/overview) sections. + +## MCP Tasks as a Transport Wrapper + +AdCP task lifecycle state is application-layer state. MCP Tasks can wrap a `tools/call` request so the MCP client, rather than the LLM, waits for the `CallToolResult`; they do not replace the AdCP `task_id`, status payloads, webhooks, or polling/reconciliation surfaces. + +A task-augmented MCP call can complete successfully after delivering an AdCP payload whose `status` is still `submitted`. From that point, the media-buy, creative, signals, or governance workflow remains open at the AdCP layer and should be observed with webhooks or AdCP polling. + +:::warning Client support is limited +Most chat-based MCP clients (Claude Desktop, Cursor) do not yet support MCP Tasks. If your client doesn't support task-augmented tool calls, use standard `tools/call` plus **webhooks** or **AdCP polling** instead — these work with any MCP client. See [Async Operations](/dist/docs/3.3.0-beta.3/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks) for transport-independent patterns. + +MCP Tasks are useful when you control the MCP client (e.g., building your own orchestrator with `@modelcontextprotocol/sdk`) and want protocol-level waiting for the initial `tools/call` result. They are optional transport plumbing, not the canonical AdCP task store. +::: + +### SDK Implementation + +If you use the `@modelcontextprotocol/sdk` package, MCP Tasks support requires minimal code. Pass an `InMemoryTaskStore` (or your own `TaskStore` implementation) to the Server constructor — the SDK auto-registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel`: + +```typescript +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 provide principal/account isolation — any client that knows a task ID can read, cancel, or list it. For production, bind every task to the authenticated principal and account at creation, then filter reads, lists, results, and cancellation by those authoritative values on every operation. `sessionId` may be retained only as secondary transport metadata; it MUST NOT grant, deny, or redirect task access because `Mcp-Session-Id` is unsigned. 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 | +| `preview_creative` | `forbidden` | Renders existing manifest | +| `list_creatives` | `forbidden` | Session state lookup | +| `get_media_buys` | `forbidden` | Session state lookup | +| `get_media_buy_delivery` | `forbidden` | Session state lookup | +| `get_creative_delivery` | `forbidden` | Session state lookup | +| `get_plan_audit_logs` | `forbidden` | Session state lookup | +| `get_brand_identity` | `forbidden` | Instant lookup | + +Tools with `taskSupport: "optional"` can be called either way: +- **Without `task` field**: Synchronous — returns the result directly +- **With `task` field**: Returns a `CreateTaskResult` immediately; poll the MCP task via transport-native `tasks/get`, retrieve the `CallToolResult` via transport-native `tasks/result`, then inspect the AdCP payload inside that result. + +### Invoking a Tool as a Task + +Include the `task` field in your `tools/call` request: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_products", + "arguments": { + "idempotency_key": "550e8400-e29b-41d4-a716-446655442005", + "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 the MCP transport task with `tasks/get` (respecting `pollInterval`) until the task reaches a terminal state (`completed`, `failed`, or `cancelled`), then retrieves the `CallToolResult` via `tasks/result`. To abort the transport task, send `tasks/cancel` with the MCP `taskId`. + +After retrieving the `CallToolResult`, inspect the AdCP response payload. If it contains `status: "submitted"` and an AdCP `task_id`, the transport task has delivered the queued AdCP response but the application workflow is still open. Continue with webhooks or AdCP polling (`get_task_status`, or legacy `tasks/get` in 3.x). + +### MCP Task Status vs. AdCP Status + +AdCP uses a richer set of statuses than MCP Tasks. If an implementation mirrors AdCP progress into a transport-native MCP Task, use this mapping only for the MCP wrapper. The AdCP payload remains the source of truth for domain workflow state: + +| AdCP Status | MCP Task Status | Notes | +|-------------|-----------------|-------| +| `working` | `working` | Direct mapping | +| `submitted` | `working` | Use `statusMessage` to indicate queued state | +| `input-required` | `input_required` | Server moves task to `input_required`, sends elicitation via `tasks/result` | +| `completed` | `completed` | Direct mapping | +| `failed` | `failed` | Direct mapping | +| `rejected` (transport lifecycle) | `failed` | MCP Tasks has no native rejected state; use `statusMessage` for the invocation-level rejection reason | +| `rejected` (task-specific business outcome) | `completed` | Store the AdCP result unchanged with payload `status: "rejected"`; direct tool calls use `isError: false` | +| `canceled` | `cancelled` | Spelling difference (AdCP uses American, MCP uses British) | +| `auth-required` | `input_required` | Elicitation requests credentials | + +### Webhooks for Long-Lived Operations + +MCP Tasks handles waiting within the MCP session, but many AdCP operations outlive a single session (e.g., a media buy that takes 24 hours for publisher approval). For these, register `push_notification_config` on the AdCP call: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "create_media_buy", + "arguments": { + "buyer_ref": "nike_q1_2025", + "packages": [], + "push_notification_config": { + "url": "https://buyer.com/webhooks/adcp/create_media_buy/op_abc123", + "authentication": { + "schemes": ["HMAC-SHA256"], + "credentials": "shared_secret_32_chars" + } + } + }, + "task": { + "ttl": 86400000 + } + } +} +``` + +The MCP Task tracks the transport wrapper within the session. The webhook tracks the AdCP application task independently and remains valid even after the MCP session ends. See [Push Notifications](/dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks) for webhook payload formats and authentication. + +## Context Management (MCP-Specific) + +MCP does not use an AdCP request-body `context_id` to maintain conversation state. When an MCP host provides session continuity, it comes from the MCP transport session and is managed by the host and server. Durable AdCP work is resumed with the task or resource identifiers declared by the task schema. + +Where the selected request schema admits `context_id`, servers MUST ignore it for session lookup, identity, account selection, authorization, task continuation, and idempotency scope. Clients MUST NOT copy a response `context_id` into later MCP tool arguments expecting continuity. + +```javascript +// Follow-up in the same MCP transport session. Do not add context_id. +const response = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442077", + buying_mode: "brief", + brief: "Focus on premium inventory" +}); +``` + +### Usage Examples + +#### Calls on One Transport Session +```javascript +const products = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442074", + buying_mode: "brief", + brief: "Sports campaign" +}); + +// Follow-up on the same MCP transport session, if the host provides one. +const refined = await mcp.call('get_products', { + idempotency_key: "550e8400-e29b-41d4-a716-446655442075", + buying_mode: "brief", + brief: "Focus on premium CTV" +}); +``` + +#### Async Operations with MCP Tasks + +For tools with `taskSupport: "optional"`, pass the `task` option to use MCP Tasks: + +```javascript +// Synchronous call (no task augmentation) +const products = await mcp.callTool({ + name: 'get_products', + arguments: { + idempotency_key: '550e8400-e29b-41d4-a716-446655442006', + buying_mode: 'brief', + brief: "Sports campaign" + } +}); + +// Task-augmented call for a long-running operation +const result = await mcp.callTool({ + name: 'create_media_buy', + arguments: { + packages: [...], + 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" + } + } + }, + task: { ttl: 86400000 } // 24-hour TTL +}); + +// result is a CreateTaskResult for the MCP wrapper. +// After tasks/result, inspect the AdCP payload; if it is still submitted, +// continue via webhook or AdCP get_task_status / legacy tasks/get. +``` + +**Webhook POST format:** +```json +{ + "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.3.0-beta.3/core/mcp-webhook-payload.json) and [Webhooks — Operation IDs](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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` — Compatibility metadata; not an MCP session selector +- `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.3.0-beta.3/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, + 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 a specific AdCP task +const taskStatus = await mcp.call('get_task_status', { + task_id: 'task_456', + include_result: true +}); +if (taskStatus.status === 'completed') { + console.log('Result:', taskStatus.result); +} + +// State reconciliation +const reconciliation = await mcp.call('list_tasks', { + filters: { statuses: ['submitted', 'working', 'input-required'] } +}); +if (reconciliation.tasks.length > 0) { + console.log('Found open AdCP tasks:', reconciliation.tasks); + // Start tracking these tasks +} + +console.log(`${reconciliation.tasks.length} operations in progress`); +``` + +**Key difference**: A2A exposes native `contextId` conversation threading. MCP session continuity is transport-local and does not use an AdCP tool argument. + +## Handling Async Operations + +When an AdCP response returns `working` or `submitted` status, you need a way to receive the result. This applies whether or not your MCP client supports MCP Tasks — the patterns below work with any client. + +| Approach | Best For | Trade-offs | +|----------|----------|------------| +| **Webhooks** | Production systems, any task duration | Handles hours/days, but requires a public endpoint | +| **Polling** | Simple integrations, short tasks | Easy to implement, but inefficient for long waits | +| **MCP Tasks** | Custom clients using the MCP SDK | Protocol-native wrapper for the initial `tools/call`, but does not replace AdCP task tracking | + +### Option 1: Webhooks (recommended) + +Configure a webhook URL and the server will POST the result when the operation completes. This is the right approach for `submitted` operations that are blocked on external dependencies (publisher approval, human review). + +```javascript +const response = await mcp.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.3.0-beta.3/building/by-layer/L3/webhooks) for payload formats and authentication. + +### Option 2: Polling (backup) + +Use AdCP polling as a backup for `submitted` operations, or when you can't expose a webhook endpoint. In 3.x, prefer `get_task_status` when the seller advertises it; otherwise use legacy `tasks/get`: + +```javascript +async function pollForResult(mcp, taskId, pollInterval = 30000) { + while (true) { + const response = await mcp.call('get_task_status', { + task_id: taskId, + include_result: true + }); + + if (['completed', 'failed', 'canceled'].includes(response.status)) { + return response; + } + + if (response.status === 'input-required') { + const input = await promptUser(response.message); + const continuation = verifiedContinuationFor(response); + if (!continuation) return requireReconciliation(response); + return resumeContinuation(continuation, input); + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } +} +``` + +### Handling different statuses + +```javascript +const initial = await mcp.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 +// Use unified status handling (see Core Concepts) +async function handleAdcpCall(tool, params) { + const response = await mcp.call(tool, params); + + switch (response.status) { + case 'input-required': + // Handle clarification (see Core Concepts for patterns) + const input = await promptUser(response.message); + const continuation = verifiedContinuationFor(response); + if (!continuation) return requireReconciliation(response); + return resumeContinuation(continuation, 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 and generic transport wrappers may send envelope-level fields admitted by the selected request schema, either as declared properties or through `additionalProperties: true`. Reference SDKs auto-generate `idempotency_key` only for mutating requests. MCP tool implementations MUST accept schema-admitted fields and ignore the ones they do not consume. An explicitly closed schema may reject undeclared fields. An admitted `context_id` is always a compatibility no-op; callers do not send it for continuity. Common traps: + +- **FastMCP / Pydantic strict signatures** — declare schema-admitted envelope fields as accept-and-ignore optionals, or use `**kwargs` for open input schemas. `model_config = ConfigDict(extra='allow')` on open 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 open input models — fix the generator config so it preserves the selected schema. + +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.3.0-beta.3/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.3.0-beta.3/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": "get_adcp_capabilities" } + ], + "_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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/operating/transport-errors). + +```javascript +try { + const response = await mcp.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. **Reuse the authenticated MCP transport session** when the host offers continuity +2. **Check status field** before processing response data +3. **Use schema-declared task or resource IDs** for durable continuation +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.3.0-beta.3/building/by-layer/L3/task-lifecycle) for status handling and workflows +- **Task Reference**: See [Media Buy Tasks](/dist/docs/3.3.0-beta.3/media-buy) and [Signals](/dist/docs/3.3.0-beta.3/signals/overview) +- **Protocol Comparison**: Compare with [A2A integration](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/by-layer/L3/task-lifecycle) - this guide focuses on MCP transport specifics only.** diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction.mdx new file mode 100644 index 00000000000..c1736ae1a8b --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction.mdx @@ -0,0 +1,174 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/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 the configured response-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 +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024; // 4 MiB default; configurable per agent and per call + +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 > MAX_RESPONSE_BYTES) continue; // same limit as the whole message + 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. + +When `structuredContent` carries the authoritative success payload, `content` MAY contain a terse human-readable summary. Producers SHOULD NOT serialize the complete AdCP payload again into `content[].text`; doing so doubles the wire bytes and model-context cost without adding information. A server supporting an older MCP client that cannot read `structuredContent` MAY instead put the JSON payload in text as the fallback form below and omit `structuredContent`. It should not emit both full copies. + +### 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.3.0-beta.3/building/operating/transport-errors#security-considerations) apply. + +### Size Limits + +For pageable reads and other read-only tasks that support field projection, producers SHOULD target at most 128 KiB per serialized MCP response; see [Managing response size](/dist/docs/3.3.0-beta.3/building/concepts/managing-response-size#interoperability-size-target). Clients SHOULD independently enforce a streamed limit on the complete serialized MCP message before buffering or JSON decoding. The recommended client safety limit is 4 MiB (4,194,304 bytes). For text fallback, also check the `content[].text` size before the inner `JSON.parse`, against that same limit rather than a tighter one — for a server that returns only `content[].text`, that item is the entire response, so a sub-limit figure there would silently become the effective cap. The larger safety limit is not a producer target: it bounds hostile or buggy input after normal interoperability guidance has failed. + +The streamed limit is the MCP instance of the general client obligation to [bound every agent response](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#response-size-bounds): count bytes as they arrive and abort rather than buffer-then-check, pre-reject an over-limit `Content-Length`, bound raw and decoded bytes, pair the cap with a deadline, bound JSON depth and node count after the cap passes, and fail with a distinct error instead of truncating. It applies to every MCP response the client reads — `tools/call`, `tools/list`, `initialize`, and the transport-native `tasks/get` and `tasks/result` results that carry an async `CallToolResult`. Leaving the async retrieval path unbounded is the same exhaustion with one extra hop. + +A Streamable HTTP server MAY answer a POST with a single `application/json` body or with an SSE stream (`text/event-stream`), and the bound applies to both — a server does not escape it by choosing SSE. On the SSE path it applies to each event and to the cumulative bytes the client retains for one request, carried across `Last-Event-ID` resumption rather than reset by it, so an oversized result cannot be split across many frames or across a reconnect. On a non-HTTP transport (stdio) there is no `Content-Length` to pre-reject; the streaming count and the per-message bound still apply. + +Clients SHOULD make the limit configurable per agent and per call. 4 MiB is the MCP default and is also the protocol-wide ceiling on a default, so a client raises the limit for a specific agent or a specific call rather than shipping a larger default. Implementations are free to default lower; the 4 MiB figure bounds hostile or buggy input after normal interoperability guidance has failed, and is not a size a producer may aim at. + +### 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. +5. **Do not require duplicated success data.** Treat summary-only `content[].text` as normal whenever `structuredContent` is present; never compare it with, or expect it to reproduce, the structured payload. + +## 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.3.0-beta.3/building/operating/transport-errors) — error extraction from MCP and A2A +- [A2A Response Extraction](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction) — equivalent spec for A2A +- [MCP Guide](/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-guide) — MCP transport integration diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L0/schemas.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L0/schemas.mdx new file mode 100644 index 00000000000..7907773ff58 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L0/schemas.mdx @@ -0,0 +1,471 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/` | 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. + +### Schema identity and offline resolution + +The `latest` tree and releases cut after this behavior was introduced use canonical HTTPS URIs for root `$id` values and external `$ref` values, for example `https://adcontextprotocol.org/schemas/{version}/core/product.json`. These URIs are stable schema identifiers; a validator does not have to fetch them over the network. Older release directories are immutable and retain their original `/schemas/...` references; for offline use of those releases, select their `bundled/` schemas. + +Every generated validation representation of a schema MUST retain the same document identity. Source files use the stable unversioned `/schemas/...` form; published modular schemas, bundled schemas, MCP projections, and MCP validation-profile compound documents MUST expose its exact-version canonical HTTPS URI. In particular, retrieval through a `bundled/` path does not add `bundled/` to the root `$id`, and an embedded schema resource retains the same `$id` it has in the modular tree. Generators MUST resolve relative fragments against the nearest resource identity and MUST NOT remove an embedded `$id` merely to flatten a document. Prompt-only model-context views are not validation representations and omit document metadata. + +Canonical, bundled, and MCP artifacts are alternate representations, not separate resources. Register one representation of a given identity in a validator registry at a time. Installed or vendored copies MUST be registered by their declared `$id` values (or by a deterministic URI-to-file mapping), so nested references continue to resolve without network access after relocation. + +When using the modular schema tree from a downloaded tarball, configure the validator or code generator to map the canonical `https://adcontextprotocol.org/schemas/{version}/` prefix to the extracted `schemas/` directory. Tools that cannot register URI-to-file mappings should use the corresponding `schemas/bundled/` artifact, which has external `$ref` values resolved inline. + +For Python's `jsonschema`, the `referencing` registry can perform that mapping without network access: + +```python +import json +from pathlib import Path + +from jsonschema import Draft7Validator +from referencing import Registry, Resource +from referencing.exceptions import NoSuchResource + +VERSION = "3.2.0" +SCHEMA_ROOT = Path(f"adcp-{VERSION}/schemas") +CANONICAL_PREFIX = f"https://adcontextprotocol.org/schemas/{VERSION}/" + +def retrieve_local_schema(uri: str) -> Resource: + if not uri.startswith(CANONICAL_PREFIX): + raise NoSuchResource(ref=uri) + relative_path = uri.removeprefix(CANONICAL_PREFIX) + contents = json.loads((SCHEMA_ROOT / relative_path).read_text()) + return Resource.from_contents(contents) + +root = json.loads( + (SCHEMA_ROOT / "media-buy/get-products-response.json").read_text() +) +validator = Draft7Validator( + root, + registry=Registry(retrieve=retrieve_local_schema), +) +``` + +## 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.3.0-beta.3/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.3.0-beta.3/building/verification/compliance-catalog) for every protocol and specialism an agent can claim. + +## Common schemas + +| Schema | URL | +|--------|-----| +| Product | `https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/product.json` | +| Media Buy | `https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/media-buy.json` | +| Creative Format | `https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/format.json` | +| Schema Registry | `https://adcontextprotocol.org/schemas/3.3.0-beta.3/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.3.0-beta.3/`, `/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.3.0-beta.3/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: + +### MCP 2026 tool-schema projection + +Beginning with AdCP 3.2, every release also includes a self-contained JSON +Schema 2020-12 projection for MCP `2026-07-28` tool discovery: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/manifest.json +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/media-buy/get-products-request.json +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/media-buy/get-products-response.json +``` + +The projection manifest maps every AdCP tool to downloadable `inputSchema` and +`outputSchema` files. Servers can select and embed these schemas when building +their `tools/list` response; publishing the artifacts does not automatically +change a server's MCP registration. MCP disables automatic network +dereferencing by default, so each projected schema is a compact, self-contained +document containing only local fragment references. + +The 3.2 projection separates MCP discovery from canonical validation. Projected +`outputSchema` files preserve the canonical draft-07 validation outcomes. +Projected `inputSchema` files are host-compatible `tools/list` hints: the build +flattens unconditional object properties and optional version fields into the +root, but omits root `allOf`, `anyOf`, `oneOf`, conditionals, and negations that +strict tool hosts reject. Nested combinators remain intact. Servers MUST +validate every call against the canonical draft-07 request schema referenced by +the version-root manifest; a payload accepted by discovery can still receive a +structured `INVALID_REQUEST` at call time. + +The build tests inspect every projected schema for dialect, local-reference +integrity, and self-containment; enforce AdCP-defined defensive depth, +schema-object, and compact-serialization byte bounds following MCP guidance; +and assert that discovery inputs retain their structural property surfaces. +Repeated shared schemas are stored once under `$defs` rather than recursively +inlined. + +#### Production surface profile + +The MCP projection also publishes a filtered production profile: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/production/manifest.json +``` + +For AdCP 3.2, this is the clean active discovery catalog: it excludes the +compliance-only controller and tools deprecated by 3.2, including the +`get_products` and `list_creative_formats` compatibility facades. Its schemas +remove low-signal presentation annotations while retaining descriptions needed +to explain root constraints omitted from discovery. Output validation semantics +remain identical to the full MCP projection; input schemas remain permissive +discovery hints. +Each manifest tool retains its `protocol` classification for deterministic +selection. + +The profile is not a recommendation to load all active AdCP tools into one +agent context. It currently spans 66 tools across the protocol families. A +production host MUST expose only the protocols and tools it implements and +SHOULD further select the smallest capability-appropriate subset for each +agent session. Use the full projection for documentation, compatibility, and +conformance; use this profile as the filtered catalog from which a host builds +that subset, and use the canonical draft-07 schemas for request validation. +Removing descriptions makes the +artifacts smaller, but does not by itself solve `tools/list` context cost. + +#### Active role catalogs + +Hosts and clients can use one of two role-filtered catalogs instead of selecting +active tools from the entire production profile: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/media-buy/manifest.json +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/creative/manifest.json +``` + +The `media-buy` catalog covers active 3.2 operations for a seller-hosted +product-to-delivery role. It includes product discovery, proposals, purchase, +control, reporting, audiences, catalogs, event sources, accounts, governance, +and separately synchronized creatives. These operations are one production +lifecycle rather than separate "sales" and "sales lifecycle" surfaces. +Creative construction is deliberately excluded. + +The `creative` catalog covers creative construction, transformation, preview, +validation, catalog inputs, library synchronization, delivery, accounts, +governance, usage, and task management. Shared account, catalog, and +creative-trafficking tools intentionally appear in both catalogs; they describe +role-oriented active surfaces, not mutually exclusive protocol ownership. + +These are active-3.2 catalogs, not complete 3.x server registrations. A server +that supports callers using the deprecated 3.x compatibility facades must also +advertise the applicable `get_products`, `create_media_buy`, and +`update_media_buy` definitions from the full projection. Cross-agent buyer +orchestration can additionally require signals, brand, external governance, +property, or content-standard services that are intentionally outside the +seller-hosted role catalog. +Clients that combine compatibility facades with an active role catalog use the +full projection's `task_result_resolution`, because the role-scoped resolver +intentionally covers only task types present in that active catalog. + +Each role also publishes an input-only client prompt view: + +``` +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/media-buy/model-context/manifest.json +https://adcontextprotocol.org/schemas/{version}/mcp/2026-07-28/profiles/creative/model-context/manifest.json +``` + +Model-context manifests contain only structural `inputSchema` entries. They are +client-side prompt projections, not standalone MCP `tools/list` registrations: +servers SHOULD continue advertising `outputSchema`, and clients SHOULD validate +structured results with the parent role catalog. A controlled client can omit +the output schema only when assembling its model prompt while retaining the +parent response schemas and task-result resolution metadata out of band. + +Structural schemas deliberately omit descriptions. To support tool selection, +clients combine the model-context inputs with each live MCP tool's concise +`name` and `description`; the downloadable model-context manifest is not a +description catalog by itself. Every tool in the published active Media Buy +and Creative role catalogs carries a concise manifest `summary` that a host can +use as that live description. + +#### Capability-selected runtime projection + +AdCP 3.2 hosts MUST derive a live MCP tool surface from the release manifest +and the tools the endpoint can actually dispatch. A runtime projection is a +selection of the generated per-tool bundles, not another schema profile and +not a second hand-maintained tool catalog. + +The deterministic selection algorithm is: + +1. Build `implemented_tools` from the endpoint's dispatch registry. Every name + MUST exist in the canonical release manifest. Do not infer implementation + from documentation, a role profile, or a protocol claim. +2. If the session has no narrower capability scope, select + `implemented_tools`. Otherwise, select the implemented tools whose manifest + `protocol` is enabled, unioned with exact enabled tool names. Convert + `supported_protocols` snake case to manifest kebab case (`media_buy` → + `media-buy`) before comparing. Exact tool claims that are not implemented + are configuration errors; hosts MUST fail closed rather than advertise + them. +3. Treat `protocol` as ownership metadata, not dependency closure. Shared task, + account, or discovery tools are included only when the host adds their exact + names. Selection never pulls in neighboring tools implicitly. +4. Production projections MUST exclude the `compliance` protocol. Deprecated + compatibility facades are included only when the endpoint really implements + and advertises them; deprecation alone is not a runtime filter. +5. Sort selected names lexicographically. For each selected name, emit one MCP + `tools/list` entry containing `name`, the optional manifest `summary` as the + live `description`, and the corresponding self-contained `inputSchema` from + the MCP projection. Do not emit unselected tools or load response schemas + into the model-facing list. +6. Keep the release manifest and response bundles available outside model + context. SDKs validate a direct result through that tool's `response_schema`; + they resolve terminal polling results through `task_result_resolution`. + +The coarse protocol set normally comes from `supported_protocols`. Exact names +come from the active capability blocks (for example `lifecycle_tools`, +`repair_tasks`, and `projection_tasks`) plus shared tools the host exposes for +that session. The implementation registry remains the upper bound in every +case. Unknown protocol or tool names, duplicate selector inputs, and production +attempts to expose compliance tools are errors. + +For caller-side version adaptation, the live discovery result is authoritative. +An SDK calls a current split 3.2 tool when that name is present. If it is absent, +the SDK checks the current tool's `legacy_fallback` manifest entry and may use +the named legacy tool only when that name is live: `direct` is a one-call +translation, `orchestrated` must preserve the current operation's state and +idempotency semantics across the sequence, and `none` is unsupported. SDKs +MUST NOT infer a fallback from a shared protocol classification. + +The checked-in `production`, `media-buy`, and `creative` profiles remain useful +catalogs and role-oriented starting points. They are not mandatory runtime +combinations. The complete canonical projection remains the authority for +documentation, conformance, compatibility analysis, code generation, and lazy +response validation. + +AdCP 4.0 will make JSON Schema 2020-12 the canonical source dialect. That +major-version migration is where the protocol may selectively use +`unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, and other +2020-12 semantics to tighten contracts. Published v3 schemas remain draft-07 +for their support lifetime. + +### 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 shared schemas and legacy task lifecycle schemas +├── mcp/2026-07-28/ # Self-contained JSON Schema 2020-12 tool projections +│ └── profiles/ +│ ├── production/ # Active, non-compliance structural catalog +│ ├── media-buy/ # Active seller media-buy catalog + prompt view +│ └── creative/ # Active creative catalog + prompt view +├── core/ # Modular schemas with $ref +├── trusted-match/ # Serve-time Context Match and Identity Match schemas +├── media-buy/ +└── index.json # Schema registry +``` + +`index.json` is the directory authority. It declares the bundle's `published_version`, stability metadata, and `protocol_layers`: the negotiation layer (`media-buy`, `creative`, `signals`, `account`, `governance`, `brand`, `sponsored-intelligence`) and the decisioning/serving layer (`trusted-match`). + +### Bundled schema categories + +All request/response task schemas are bundled: + +| Category | Tasks | +|----------|-------| +| `bundled/media-buy/` | get-products, create-media-buy, update-media-buy, list-creative-formats, sync-creatives, build-creative, list-creatives, get-media-buy-delivery, list-authorized-properties, provide-performance-feedback | +| `bundled/creative/` | list-creative-formats, preview-creative | +| `bundled/signals/` | get-signals, activate-signal | +| `bundled/property/` | create-property-list, get-property-list, list-property-lists, update-property-list, delete-property-list, validate-property-delivery | +| `bundled/content-standards/` | create-content-standards, get-content-standards, list-content-standards, update-content-standards, calibrate-content, validate-content-delivery, get-media-buy-artifacts | +| `bundled/sponsored-intelligence/` | si-get-offering, si-initiate-session, si-send-message, si-terminate-session | +| `bundled/protocol/` | get-adcp-capabilities, get-task-status, list-tasks | +| `bundled/core/` | tasks-get, tasks-list | + +See the [schema registry](https://adcontextprotocol.org/schemas/3.3.0-beta.3/index.json) for all available schemas. + +## Version discovery + +```bash +# Get the canonical stable schema bundle and registry URL. +curl https://adcontextprotocol.org/schemas/latest.json | jq '{version: .latest_stable, index}' + +# Or read the full file-based discovery index. +curl https://adcontextprotocol.org/schemas/index.json | jq '.aliases' + +# The versioned registry also carries the full semver of its bundle. +# (Note: `published_version` carries full semver including patch. +# It's distinct from the per-request/response wire `adcp_version` +# field defined in core/version-envelope.json, which uses +# release-precision — never send `published_version` on the wire.) +curl https://adcontextprotocol.org/schemas/3.3.0-beta.3/index.json | jq '.published_version' +``` + +Do not infer the canonical version from directory order or `versions[0]`; pre-release artifacts remain discoverable for pinned historical builds. Use `latest_stable` or the `aliases` map for canonical stable selection. + +Check [Release Notes](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/by-layer/L1/index.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L1/index.mdx new file mode 100644 index 00000000000..d63b36c25cb --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L1/index.mdx @@ -0,0 +1,27 @@ +--- +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.3.0-beta.3/building/cross-cutting/sdk-stack#l1--identity--signing). + +## Pages in this layer + +- **[Security implementation profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security)** — RFC 9421 wire details, KMS integration, replay-window tuning. +- **[Production security profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/production-security-profile)** — the optional `adcp-prod-security-3.3` profile: signing, revocation freshness, and acts-for checks an endpoint commits to enforce. +- **[Portable attestations](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#portable-attestations)** — reference-first claim credentials, evaluator allowlists, safe resolution, verification outcomes, and action binding. +- **[Webhook verifier tuning](/dist/docs/3.3.0-beta.3/building/by-layer/L1/webhook-verifier-tuning)** — clock-skew handling, key-rotation transitions, signature failure diagnostics. +- **[Web Bot Auth request-signing profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile)** — experimental in 3.3, adopted by DR-0023: one key-resolution rule from the covered `Signature-Agent` header, identity as an origin, mandatory nonce checks, one signature per request, and status-code error responses. diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L1/production-security-profile.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L1/production-security-profile.mdx new file mode 100644 index 00000000000..d412b7b7180 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L1/production-security-profile.mdx @@ -0,0 +1,134 @@ +--- +title: Production security profile +sidebarTitle: Production security profile +description: "The adcp-prod-security-3.3 profile: an optional, capability-advertised commitment to enforce signing, revocation freshness, and acts-for checks on spend-committing operations." +"og:title": "AdCP — Production security profile" +--- + + +**Experimental (`security.profiles`).** The `adcp-prod-security-3.3` profile and the `security_profiles` capability ship in AdCP 3.3 as an experimental surface. An endpoint that advertises a profile also lists `security.profiles` in `experimental_features`. The surface graduates under [Experimental status](/dist/docs/3.3.0-beta.3/reference/experimental-status) once a second implementation passes the conformance storyboards. Decisions are recorded on [#7066](https://github.com/adcontextprotocol/adcp/issues/7066). + + + +`adcp-prod-security-3.3` is a named profile an endpoint can advertise in [`security_profiles`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities#security_profiles). It is stronger than the 3.x baseline, it can be tested, and it adds no obligation for an endpoint that does not advertise it. In 4.0, signing for spend-committing operations becomes a universal requirement (see [Transport migration timeline](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-migration-timeline)); this profile lets endpoints adopt that floor and the controls around it early. + +Advertising the id is a commitment to the requirements below on the advertiser's own endpoint. In 3.3, rejecting counterparties that do not conform ([Counterparty rules](#counterparty-rules)) is a SHOULD. In 4.0 it becomes a MUST, together with the universal signing floor ([#2307](https://github.com/adcontextprotocol/adcp/issues/2307)). A buyer that needs the stronger guarantee in 3.3 relies on the seller's `request_signing.required_for` and on the [downgrade rules](#downgrade-rules). + +## Scope of the claim + +- The claim covers the whole endpoint: every tenant and every transport that serves a spend-committing operation. A multi-tenant platform where one tenant still authenticates with a bearer token alone does not meet it. Serve those tenants from another endpoint. +- Only an agent that verifies inbound requests and serves `get_adcp_capabilities` can advertise it. A buyer-only agent advertises nothing. A seller judges its conformance by what it verifies on each request, as in [Counterparty rules](#counterparty-rules). +- The claim is self-asserted until a registry verifies it. Verification is outside this profile. +- The profile is defined over properties of a signed request, not over one signing profile. See [Signing profiles](#signing-profiles). + +## Spend-committing operations + +The profile uses one closed list. An endpoint applies each requirement to every operation below that it supports, and to any other operation it chooses to list in `request_signing.required_for`: + +`create_media_buy`, `buy_products`, `accept_proposal`, `update_media_buy`, `control_media_buy`, `acquire_rights`, `update_rights`, `activate_signal`. + +Adding an operation to this list creates a new profile id. + +## Admission preconditions + +An endpoint advertises the profile only while all of these hold. Each uses an existing optional field, so no endpoint that omits `security_profiles` is affected. + +| # | Precondition | +|---|---| +| A1 | `request_signing.supported` is `true`, and the advertised signing profile covers a digest of the request body: `covers_content_digest` is `"required"` for `adcp/request-signing/v1`, and the AdCP [Web Bot Auth profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile#profile-rules) ([#7894](https://github.com/adcontextprotocol/adcp/pull/7894)) requires `content-digest` on every request with a body. | +| A2 | `request_signing.required_for` and `supported_for` include every spend-committing operation the endpoint supports. | +| A3 | Verifiers can resolve the endpoint's own keys under the signing profile it advertises: `identity.brand_json_url` for `adcp/request-signing/v1`, or the key directory at the endpoint's origin for `web-bot-auth`. | +| A4 | `idempotency.supported` is `true`. | +| A5 | If the endpoint emits webhooks, `webhook_signing.supported` is `true` and `legacy_hmac_fallback` is `false`. For requests served at 3.3 or later, this covers every webhook surface, including `reporting_webhook` and `artifact_webhook`: a registration without `authentication` is signed under the profile its `signing_profile` selects (`adcp/webhook-signing/v1` when omitted), as [Webhook callbacks](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#webhook-callbacks) describes. Below 3.3 those two registrations require `authentication`, so they always select the legacy scheme. | +| A6 | `request_signing.operation_sources` is present and lists every source the endpoint resolves operations from. It includes `a2a_invocation_skill` if the endpoint serves any spend-committing operation over A2A, and `mcp_tools_call` if it does so over MCP. Without the A2A marker, a counterparty cannot infer that `required_for` is enforced over A2A. | + +A precondition that stops holding withdraws the claim. The endpoint removes the id from `security_profiles` in the same change. + +## Requirements + +Each requirement applies to the spend-committing operations above. + +| # | Requirement | +|---|---| +| P1 | **Signature required (SHOULD in 3.3, MUST in 4.0).** An unsigned request is rejected: `request_signature_required` under `adcp/request-signing/v1`; under `web-bot-auth`, with the answer the Unsigned requests rule in [Web Bot Auth identities](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities) points to, which also covers a verifier that advertises both profiles. Over A2A, the hardening clauses of [Operation resolution over A2A](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#operation-resolution-over-a2a) are MUST from 3.3 and apply here: strict parse before classification, Part shape, and task continuation binding. | +| P2 | **No unsigned fallback (SHOULD in 3.3, MUST in 4.0).** A bearer token, API key, OAuth token, or mTLS identity is never enough on its own. A seller MAY still use one to select the principal alongside a valid signature. P1 and P2 are the opt-in to the **Strict** posture in [Composition with fallback authenticators](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#composition-with-fallback-authenticators), through its "stop accepting" branch. The per-caller flag branch does not meet the profile, because the claim covers every caller on the endpoint. | +| P3 | **Revocation freshness (15 minutes).** See [Revocation freshness](#revocation-freshness). | +| P4 | **Acts-for.** The verified identity (the canonical signed-Agent URL under `adcp/request-signing/v1`, the canonical origin under `web-bot-auth`), and nothing asserted in the body or an unsigned header, maps to a seller-side principal. When independent authentication also selects a principal, the two MUST be the same principal. The endpoint then checks that the principal may act for the operator of the request's `account` (the `operator` in a natural-key reference, or the operator bound to an `account_id` at provisioning), using its onboarding record and the House Portfolio `authorized_operators[]` check that already applies, plus grants in trust.json evaluated as [Grants in trust records](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental) describes, once [#6033](https://github.com/adcontextprotocol/adcp/issues/6033) lands. A relay that re-signs a request is the signer, and the endpoint MUST NOT infer authority for an upstream agent named only in the body; the only multi-party authority is the delegation chain defined there. A failed check rejects with `PERMISSION_DENIED` (with `grant_absent` when no grant covers the claim), subject to the cross-tenant onboarding-oracle clamp in [error handling](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling). | +| P5 | **Governance binding.** If the endpoint accepts `sync_governance`, it treats that operation like a spend-committing one for P1 and P2, and declares non-empty `adcp.governance_enforcement` acceptance criteria that it enforces. A governance agent outside them is rejected with `GOVERNANCE_AGENT_NOT_ACCEPTED`, so a stolen signing key cannot bind the attacker's own governance agent. An empty or accept-all list does not meet P5. | +| P7 | **Least privilege.** The endpoint enforces task and field scopes from the account's `authorization` object (`SCOPE_INSUFFICIENT`, `FIELD_NOT_PERMITTED`). | +| P8 | **Outbound fetch safety.** Every fetch of a buyer-supplied URL (webhook URL, `brand_json_url`, `jwks_uri`, a key directory, a trust record, capabilities) follows [Webhook URL validation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#webhook-url-validation-ssrf). The principal-bound allowlist contract for asset and artifact retrieval ([#5669](https://github.com/adcontextprotocol/adcp/issues/5669)) is not part of this profile. | + +### Revocation freshness + +A compromised but validly signed buyer is bounded by how fast the endpoint learns of the revocation. The 3.2 baseline ([Revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#revocation)) allows about 150 minutes before it fails closed. Under this profile, with T = 15 minutes: + +1. The verifier polls the signer's signed revocation list at least every T/3, and at least as often as `next_update` requires. Conditional requests keep this cheap. +2. A spend-committing request is rejected with the stale-revocation outcome in [Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation) if the verifier's last successful refresh is older than T. A refresh counts only if the response was fetched over validated TLS, and a `304` counts on that condition. There is no grace multiplier. At T/3 polling, two failed fetches in a row are tolerated and a third halts spend. +3. The verifier keeps every revocation it has seen, as [Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation) requires under the Web Bot Auth profile; this profile applies that rule to `adcp/request-signing/v1` signers too. Under this profile only, the verifier also rejects a list whose `updated` is older than the newest it accepted, and rejects a list whose `updated` is more than 60 seconds in the future. A rolled-back copy cannot un-revoke a key, and a future-dated list cannot freeze a signer. +4. A signer that serves no signed revocation list gets the outcome in [Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation). A buyer that wants to transact spend with a profile endpoint publishes one. The list is a static file and the signer's `next_update` cadence is not constrained. + +The end-to-end bound is the signer's publication delay plus T. The endpoint can also suspend a principal at once (`AGENT_SUSPENDED`), which takes effect on the next request and does not wait for a list. + +### Revocation and committed work + +Revocation stops new verification and does not unwind work already accepted; the rules, under both signing profiles, are in [Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation). + +## Counterparty rules + +On spend-committing operations, a seller that advertises the profile SHOULD reject anything it can verify as non-conforming in 3.3, and MUST in 4.0. Codes are named for `adcp/request-signing/v1`; under `web-bot-auth` each row's outcome is the one [Web Bot Auth identities](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities) and [Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation) give for the same condition. + +| Condition | Result | +|---|---| +| No signature | `request_signature_required` (P1, P2) | +| Signature fails any verifier check | The matching `request_signature_*` code | +| Key revoked, or the revocation view is stale | `request_signature_key_revoked`, `request_signature_revocation_stale` (P3) | +| Signer maps to no principal, or the principal may not act for the operator | `PERMISSION_DENIED` (P4) | +| Principal has no ceiling or has reached it, where the seller implements one | `PERMISSION_DENIED`, `AGENT_SUSPENDED` ([Spend-velocity ceiling](#spend-velocity-ceiling-recommended)) | + +This list is the whole of what the advertisement obliges a seller to impose on counterparties. Practices a counterparty keeps on its own side, such as those in [Practices outside the profile id](#practices-outside-the-profile-id), cannot be enforced on it, so the profile does not claim to. + +## Downgrade rules + +A seller's capabilities are fetched over TLS and describe the seller only. These rules keep a buyer from silently falling back. + +1. **Required profile.** A buyer configured to require `adcp-prod-security-3.3` for a counterparty MUST NOT send that counterparty a spend-committing call unless the counterparty's current capabilities list it. It refuses the call. It does not warn and continue, and it does not retry with a bearer token. +2. **Observed then absent.** A buyer that has seen the id from a counterparty and later sees it absent, or sees an admission precondition become false, refuses spend-committing calls to that counterparty and alerts an operator. Until the buyer-track harness can grade them, the mechanics are SHOULD: the buyer SHOULD lift the refusal only on an operator's explicit re-pin; SHOULD treat the id as absent only when two consecutive successfully fetched, validated responses lack it, never on a fetch error or a stale cache; and SHOULD key the observation by the counterparty's canonical agent URL (its canonical origin under `web-bot-auth`) and store it durably so a restart does not clear it. A changed URL is a new counterparty that needs its own pin. A seller that retires an id SHOULD list its successor beside it for an overlap period, so operators re-pin deliberately. +3. **Misdeclaration.** These rules defend against removal, not against a counterparty that declares the id falsely. A buyer cannot observe that a seller disabled its bearer fallback. That gap closes only with registry verification or storyboard grading, which is outside this profile. + +## Signing profiles + +P1 to P3 are written over properties, so the profile does not depend on which signing profile an endpoint advertises. A signing profile qualifies when its text guarantees all of: + +- the signature covers a digest of the request body; +- every signed request carries a per-signer unique nonce and the verifier rejects a repeat within the signature's lifetime; +- a verified key resolves to exactly one identity, which the seller binds to at most one principal; +- the verifier can check that key against a signed revocation list. + +Both AdCP signing profiles meet these. The RFC 9421 profile (`adcp/request-signing/v1`, deprecated in 3.3) does through brand.json key resolution. The Web Bot Auth profile (`web-bot-auth`, [DR-0023](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0023-agent-identity-is-an-origin.md)) does because a seller binds each origin to at most one principal and checks the origin's revocation list. + +## Practices outside the profile id + +These controls matter but are SHOULD-level in 3.3, are **not** covered by the id, and several become universal requirements in 4.0. + +### Spend-velocity ceiling (recommended) + +Revocation does not help when the signer is intact but its agent was manipulated, for example by a prompt injection that leads it to buy. A ceiling bounds that loss. It is a SHOULD and is **not** covered by the profile id; an endpoint that implements it follows these rules. + +- The endpoint configures a ceiling on cumulative spend-committing commitments per **principal**, where the principal is the seller's mapping of the verified identity (the canonical signed-Agent URL under `adcp/request-signing/v1`, the canonical origin under `web-bot-auth`). It sums across all of the principal's accounts. It is keyed by neither `keyid`, account, nor transport session, so a key rotation, a new account, or a new MCP session cannot reset it. If one agent is onboarded under more than one principal, the lowest applicable ceiling applies to the spend it signs. +- Only operations that commit or raise spend count against the ceiling and can be rejected for exceeding it. A pause, a cancellation, or a budget reduction is never blocked by it, so a tripped agent can still stop its own spend. +- Deny by default is recommended: a principal with no configured ceiling cannot commit spend, and the request is rejected with `PERMISSION_DENIED`. +- The value and the window are deployment-defined and are not declared in capabilities. A seller SHOULD state them to the buyer at onboarding, and a seller whose buyers span many advertisers sets the ceiling for that spread. +- A request that would exceed the ceiling is rejected with `AGENT_SUSPENDED`. The trip is an automatic suspension of the agent's commercial relationship, and it holds until the seller's operator lifts it. The code is not retryable, so an agent does not loop on a halted budget. A key thief can use up the remaining headroom before the trip, and the profile accepts that: the ceiling bounds the loss and does not prevent it. + +### Other practices + +- **Context fencing.** Treat free text and tool responses from a counterparty as data. Delimit them from instructions before they reach an LLM context window, bound their length, and give them no authority to call tools or move money. A valid signature authenticates the sender, not the content. +- **Deterministic spend authorization.** Run ceilings, acts-for checks, and governance thresholds in ordinary code. An LLM never holds the signing key and never decides whether a limit applies. +- **Governance gate.** Require a valid `governance_context` token for spend above a deployment-defined threshold. The token binds the amount, task, and payload hash, so a stolen signing key alone does not authorize the spend. +- **Audit.** Retain and correlate the events listed in [Required log events](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#required-log-events). +- **Anomaly and rate limits.** Limit proposal probing, hold squatting, and spend changes. +- **Credential lifetimes.** Keep any bearer token used to select a principal to 1 hour or less, and to 15 minutes or less above the deployment's material spend threshold. + +## Not in this profile + +A universal signing requirement, normative rate-limit enforcement, a publisher-side requirement that agents hold the profile, and multi-hop signature chains are not part of `adcp-prod-security-3.3`. They are 4.0 or later work. diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing.mdx new file mode 100644 index 00000000000..6950404d380 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing.mdx @@ -0,0 +1,923 @@ +--- +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 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 remains **optional through AdCP 3.2** and becomes **mandatory in AdCP 4.0** for all spend-committing operations. Starting in 3.2, however, every accepted signature on a request with a body must cover `Content-Digest`. Agents that don't support signing may continue using their existing authentication path. + + +**Two signing profiles in AdCP 3.3.** This guide's steps cover the **AdCP RFC 9421 profile** (`adcp/request-signing/v1`, keys published through brand.json). That profile is **deprecated in 3.3**: it keeps working through 3.x and is removed in 4.0. The experimental **Web Bot Auth profile** (`web-bot-auth`) replaces it. New implementations should plan for Web Bot Auth; see [Web Bot Auth profile](#web-bot-auth-profile-33-experimental) below. Its SDK helpers are in progress. + + + +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.3.0-beta.3/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.3.0-beta.3/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 exact request body bytes; REQUIRED for every body-bearing request signed under the 3.2 profile + +If any covered component changes after signing, verification fails. + +For DNS hosts, signers and verifiers MUST remove one trailing root-label dot +after IDNA ToASCII processing and before deriving both `@target-uri` and +`@authority`. Thus `https://example.com./p` signs as +`https://example.com/p` with authority `example.com`; U-label and A-label IDN +forms produce the same bytes. Multiple trailing dots are malformed and MUST be +rejected. This codifies the shipping SDK behavior under +[DR-0008](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0008-sdk-canon-for-wire-contracts.md). + +### Key separation + +Every agent publishes signing keys with a distinct `kid` and `adcp_use` tag: + +- `adcp_use: "request-signing"` — for signing outbound tool calls and outbound webhooks +- `adcp_use: "webhook-signing"` — deprecated; still accepted on the webhook path for backward compatibility + +For blast-radius isolation, publish a second `request-signing` key under a separate `kid` for webhook delivery rather than reusing the same key material for tool calls and webhooks. + +### Discovery chain + +Verifiers find your public key starting from your agent URL: + +``` +Your agent URL (e.g., https://agent.example.com/mcp) + -> get_adcp_capabilities # protocol call to your agent + -> identity.brand_json_url # pointer to your operator brand.json + -> agents[] entry matching your URL # operator attests the agent + -> jwks_uri # pointer to your key store + -> JWKS # public keys, matched by keyid +``` + +The verifier calls your agent, not your domain's `/.well-known/brand.json`. Many brand.json files can list the same agent URL, so only the one your agent points to counts. The domain-derived `/.well-known/brand.json` lookup is a 3.x fallback for webhooks from older sellers, never for request signatures. The full algorithm and its error codes are in [Discovering an agent's signing keys via `brand_json_url`](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url). + +The TypeScript SDK's `BrandJsonJwksResolver` (see [JWKS resolver options](#jwks-resolver-options)) runs this chain with caching and refresh. It calls `get_adcp_capabilities` on the agent URL, including when the seller already holds an onboarding record for the agent. + +## 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 the webhook RFC 9421 profile. Publish the corresponding public key with `adcp_use: "request-signing"`; if you want webhook-only key material, use a separate `kid`. + + + +### Supported algorithms + +| Algorithm | `alg` value | Key type | Notes | +|---|---|---|---| +| Ed25519 | `ed25519` (RFC 9421) / `EdDSA` (JWK) | `OKP` / `Ed25519` | Preferred. Fast, small signatures. | +| ECDSA P-256 | `ecdsa-p256-sha256` (RFC 9421) / `ES256` (JWK) | `EC` / `P-256` | Edge-runtime friendly (Cloudflare Workers, Vercel Edge). | + + +The algorithm name differs between the JWK entry (`"alg": "EdDSA"`) and the RFC 9421 `Signature-Input` parameter (`alg="ed25519"`). See the [algorithm naming table](/dist/docs/3.3.0-beta.3/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 use separate key material for webhook delivery, publish a second `request-signing` JWK with a distinct `kid`. Deprecated `webhook-signing` JWKs remain accepted on the webhook path for backward compatibility. + +### brand.json + +Serve at `/.well-known/brand.json` on your brand domain. The `agents[].url` must be your exact agent URL, and the `jwks_uri` is how verifiers find your keys: + +```json +{ + "name": "My Company", + "domain": "example.com", + "agents": [ + { + "type": "buying", + "id": "media_buyer", + "url": "https://agent.example.com", + "jwks_uri": "https://agent.example.com/.well-known/jwks.json" + } + ] +} +``` + +### Capabilities pointer + +Your agent's `get_adcp_capabilities` response carries `identity.brand_json_url`, the HTTPS URL of the brand.json above. The host of that URL must share a registrable domain (eTLD+1) with your agent URL, unless the brand.json delegates your agent's domain through `authorized_operators[]`. + +```json +{ + "identity": { + "brand_json_url": "https://example.com/.well-known/brand.json" + } +} +``` + + +**Buyers that only call sellers still answer `get_adcp_capabilities`.** A verifier starts from your agent URL, so a buyer that signs requests must answer `get_adcp_capabilities` there, over MCP or A2A, even if it hosts no other tools. A seller's onboarding record doesn't remove this. The record must be re-confirmed against your `identity.brand_json_url` within the brand.json cache lifetime (see [Agent resolution](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#agent-resolution)). A buyer that serves nothing at its agent URL fails verification with `request_signature_capabilities_unreachable`. + +Verifiers read only `identity` from this response. The response schema still requires `adcp` and a non-empty `supported_protocols`, whose values commit the agent to the matching seller-side storyboards, and there is no buyer-only value yet. A path for signers with no capabilities endpoint, and a response shape for buyers that serve nothing else, are under discussion in [#6105](https://github.com/adcontextprotocol/adcp/issues/6105). + + +## 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`. + +A JWKS resolver covers one JWKS, so it's bound to one buyer agent. `CachingJwksResolver` takes that agent's `jwks_uri`. A seller with several buyers keeps one resolver per onboarded agent and picks it by `keyid`. The request carries no agent URL, so the `keyid` → agent index is built from onboarding records (see [Seller walkthrough: buyer-declared accounts](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#seller-walkthrough-buyer-declared-accounts)). `VerifyOptions` also needs the per-request `now` and `operation`, so build it for each request and reuse one replay store: + +```python test=false +import json +import time + +from fastapi import FastAPI, HTTPException, Request +from adcp.signing import ( + AgentResolverError, + InMemoryReplayStore, + StaticJwksResolver, + VerifierCapability, + VerifyOptions, + async_resolve_agent, + check_key_origin_consistency, + parse_signature_input_header, +) +from adcp.signing.errors import SignatureVerificationError +from adcp.signing.middleware import ( + unauthorized_response_headers, + verify_starlette_request, +) + +app = FastAPI() + +SIGNING_PROFILE = "3.2" # the profile this endpoint advertises; never read it from the request +SIG_LABEL = "sig1" +CAPABILITY = VerifierCapability( + supported=True, + covers_content_digest="required", + supported_for=frozenset({"create_media_buy", "update_media_buy"}), + required_for=frozenset({"create_media_buy", "update_media_buy"}), +) +REPLAY_STORE = InMemoryReplayStore() # one per process; use a shared store across replicas +NO_KEYS = StaticJwksResolver({"keys": []}) # unknown keyid -> request_signature_key_unknown +AGENT_INDEX: dict[str, tuple[str, StaticJwksResolver]] = {} # kid -> (agent URL, its keys) + + +async def rebuild_agent_index(onboarded_agent_urls: list[str]) -> None: + """Run at startup and at least once per revocation polling interval. + + Agent URLs come from your onboarding records, never from a request. + """ + global AGENT_INDEX + owners: dict[str, list[tuple[str, StaticJwksResolver]]] = {} + for url in onboarded_agent_urls: + try: + res = await async_resolve_agent(url, agent_type="buying") + check_key_origin_consistency( + jwks_uri=res.jwks_uri, key_origins=res.key_origins, purpose="request_signing" + ) + except (AgentResolverError, SignatureVerificationError): + continue # delisted or misconfigured: the agent drops out of the index + agent = (res.agent_entry["url"], StaticJwksResolver(res.jwks)) + for key in res.jwks.get("keys", []): + owners.setdefault(key.get("kid"), []).append(agent) + # A kid published by more than one agent maps to neither: both fail closed. + AGENT_INDEX = {kid: agents[0] for kid, agents in owners.items() if kid and len(agents) == 1} + + +def _reject_duplicate_keys(pairs): + obj = {} + for key, value in pairs: + if key in obj: + raise ValueError(f"duplicate key {key!r}") + obj[key] = value + return obj + + +def parse_envelope(body: bytes) -> dict | None: + """Parse once, strictly (checklist step 14). None means malformed.""" + try: + envelope = json.loads(body, object_pairs_hook=_reject_duplicate_keys) + except ValueError: + return None + return envelope if isinstance(envelope, dict) else None + + +def mcp_operation(envelope: dict | None) -> str: + if envelope is None or envelope.get("method") != "tools/call": + return "" # JSON-RPC protocol methods are matched by protocol_methods_*, not here + params = envelope.get("params") + name = params.get("name") if isinstance(params, dict) else None + return name if isinstance(name, str) else "" + + +def signature_keyid(headers) -> str | None: + try: + label = parse_signature_input_header(headers.get("signature-input", ""))[SIG_LABEL] + except (ValueError, KeyError): + return None # the verifier reports the precise header error + return str(label.params.get("keyid")) + + +@app.post("/mcp") +async def mcp(request: Request): + body = await request.body() + envelope = parse_envelope(body) + operation = mcp_operation(envelope) + + if "signature" not in request.headers and "signature-input" not in request.headers: + if envelope is None: + raise HTTPException(status_code=400, detail="malformed JSON-RPC body") + principal = authenticate_fallback(request, envelope, operation) # bearer / API key / mTLS + if principal is None and operation in CAPABILITY.required_for: + raise HTTPException( + status_code=401, + detail="request_signature_required", + headers={"WWW-Authenticate": 'Signature error="request_signature_required"'}, + ) + if principal is None: + return auth_error(request) # AUTH_MISSING, or AUTH_INVALID for a rejected credential + return await handle_mcp(envelope, principal) + + agent_url, jwks = AGENT_INDEX.get(signature_keyid(request.headers), (None, NO_KEYS)) + options = VerifyOptions( + now=time.time(), + capability=CAPABILITY, + operation=operation, + jwks_resolver=jwks, + agent_url=agent_url, # copied onto signer.agent_url + replay_store=REPLAY_STORE, + label=SIG_LABEL, + signing_profile_version=SIGNING_PROFILE, + ) + try: + signer = await verify_starlette_request(request, options=options) + if envelope is None: + # Verified first so step 13 burned the nonce; now reject the ambiguous body. + raise SignatureVerificationError("request_body_malformed", step=14) + 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 are available for audit. + return await handle_mcp(envelope, signer) +``` + +`authenticate_fallback`, `auth_error`, and `handle_mcp` stand for your own code, which is why the block is marked `test=false`. Some points the example depends on: + +- **Unsigned requests.** `authenticate_fallback` returns `None` for a credential you don't accept. It also has to refuse an operation where signing must be unconditional (the strict posture), and, while `legacy_hmac_fallback` is `true` (deprecated in 3.x), a payload that carries a webhook `authentication` block when you support signing. Those cases get `request_signature_required` too. A seller with `legacy_hmac_fallback: false` passes that payload through, and its task handler rejects the block with `UNSUPPORTED_FEATURE` ([Composition with fallback authenticators](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#composition-with-fallback-authenticators)). +- **One strict parse.** The SDK verifier doesn't run the step-14 duplicate-key check, so the example parses the body once with duplicate keys rejected and hands `envelope` to `handle_mcp`. Business logic never parses the body again. +- **Key rotation.** A `kid` published between rebuilds fails with `request_signature_key_unknown` until the next rebuild, so rebuild at least as often as your JWKS cache TTL. Never fall back to trying every agent's keys. +- **Blocking fetches.** `StaticJwksResolver` never fetches, which keeps the request path off the network. `CachingJwksResolver(jwks_uri=...)` refetches on a `kid` miss, but it fetches synchronously. If you use it in an async handler, prime it at startup or call `verify_request_signature` through `run_in_threadpool`. + +The SDK doesn't derive `signer.agent_url` from the request. It copies the `agent_url` you pass in `VerifyOptions`, and it's `None` when you pass none. `await verify_from_agent_url(request, agent_url, agent_type="buying", operation=..., capability=CAPABILITY)` runs the full discovery chain and fills `agent_url` from the matched `agents[]` entry. It fetches on every call, so pass an agent URL from your onboarding record, never one taken from the request. Always pass `capability`: the default is `VerifierCapability(supported=True)`, which accepts signatures without `content-digest`, and the helper can't set `signing_profile_version`, so it verifies under the 3.1 profile. Pass `revocation_checker` (for example, `CachingRevocationChecker`) if you track revoked keys. + +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: 'required' }, + 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="request-signing" (deprecated +// "webhook-signing" also accepted), Content-Digest required, no required_for +// gating (webhooks always carry signatures). +webhookMW := signing.Middleware(signing.MiddlewareOptions{ + Resolver: signing.NewBrandJSONJWKSResolver(), + Replay: signing.NewMemoryReplayStore(0), + Revocation: signing.NewStaticRevocationSource(nil), + Profile: signing.ProfileWebhookSigning, + ContentDigestPolicy: signing.DigestRequired, +}) + +http.Handle("/webhook", webhookMW(webhookHandler)) + +func webhookHandler(w http.ResponseWriter, r *http.Request) { + sender := signing.VerifiedSignerFromContext(r.Context()) + // sender.KeyID, sender.AgentURL — process the verified webhook. +} +``` + + + +## Step 6: Sign outbound webhooks (seller) + + + +Pass a `signerKey` to `createAdcpServer` and the framework signs every outbound webhook automatically: + +```typescript +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 the webhook signing public key as a JWK with `"adcp_use": "request-signing"`. The webhook verifier still accepts deprecated `"webhook-signing"` keys for backward compatibility, but new signers should use `request-signing`. If you want independent webhook rotation or blast-radius isolation, publish a separate `request-signing` JWK with a webhook-specific `kid`. + +## Step 7: Declare the capability + +If your seller verifies inbound signatures, declare `signed_requests` (alias `request_signing` in the on-wire schema) in your `get_adcp_capabilities` response so buyers know to sign: + +For AdCP 3.2, `covers_content_digest: 'required'` is part of the signing profile rather than an operation-level choice. The operation lists still control where signing is supported, shadowed, or required; they never weaken body binding. Keep `either` or `forbidden` only on an explicitly configured 3.0/3.1 compatibility endpoint. + + + +```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: 'required', + }, + }, + }, + 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": "required", + }, + ) +``` + + +```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: "required", + }, +}), nil +``` + + + +Buyers call `get_adcp_capabilities` and read `request_signing.required_for` and `supported_for` to know which operations you expect them to sign. + +## Web Bot Auth profile (3.3, experimental) + +The Web Bot Auth profile follows the IETF Web Bot Auth draft as written, so any conforming Web Bot Auth library can sign for AdCP. Its wire rules (signature parameters and algorithms, the key directory, nonce and replay, and failure responses) are on the [Web Bot Auth profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile) page ([#7894](https://github.com/adcontextprotocol/adcp/pull/7894)), which also compares it with the RFC 9421 profile above. Identity, trust records, binding, and authorization are in [Web Bot Auth identities](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#web-bot-auth-identities). This section lists the setup steps only. + +**Signer setup.** These steps let a seller verify which origin is calling: +1. Give each agent its own origin. An agency hosting several agents on one host moves each to a subdomain. +2. Serve the key directory at that origin, as [Key directories](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile#key-directories) specifies. Regenerate it before its signatures expire. +3. Sign each request and webhook as [Profile rules](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile#profile-rules) specifies, for sellers that advertise `web-bot-auth` in `request_signing.profiles` (added by [#7894](https://github.com/adcontextprotocol/adcp/pull/7894)). + +**To be bound by a seller:** + +4. Serve a signed revocation list at the origin, `/.well-known/governance-revocations.json` ([Transport revocation](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-revocation)), even while it revokes nothing. Without it, a seller can verify you but cannot bind you for spend. +5. List the origin in `https://{registrable domain}/.well-known/trust.json` with its role (`adcp:buying` for a buyer). The record carries no keys. + +**Verifier setup:** +1. Verify signatures with the profile's [Verification](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile#verification) procedure, and answer failures as its [Error responses](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile#error-responses) section says. +2. Treat a verified origin you have not bound as identity-established: no account access. Evaluate the operator's trust record only when you bind a principal, check a grant, or apply an `open` onboarding mode. +3. Once you have bound an origin, refuse RFC 9421 signatures for it. +4. Advertise `web-bot-auth` in `request_signing.profiles` (added by [#7894](https://github.com/adcontextprotocol/adcp/pull/7894)). + +For the end-to-end buyer flow, see [A buyer agent onboards itself](/dist/docs/3.3.0-beta.3/accounts/buyer-agent-self-onboarding). For moving from the RFC 9421 profile, see the [agent identity migration guide](/dist/docs/3.3.0-beta.3/reference/migration/agent-identity-3-3). + +## 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.3.0-beta.3/building/by-layer/L1/security#revocation) for the revocation list format. + +## Testing + +### Conformance vectors + +The spec ships legacy-profile wire vectors, dedicated 3.2 wire vectors, and the 3.2 post-parse body-integrity reference-evaluator matrix at `/compliance/latest/test-vectors/request-signing/` (source at `static/compliance/source/test-vectors/request-signing/`). Select fixtures by `signing_profile_version`; the root positive and negative directories are 3.1 compatibility fixtures, not 3.2 conformance cases. + +- **Legacy 3.1 positive vectors**: valid signed requests using the legacy binary encoding +- **Legacy 3.1 negative vectors**: invalid requests a 3.1 verifier must reject with the stated error code +- **Profile-3.2 wire vectors**: 3.2 versions of the legacy positive vectors and of the checklist step 2–12 negatives. They sign with `content-digest` covered (except the one negative that tests its absence) and assume a verifier advertising `covers_content_digest: "required"`. The set also includes rejections of malformed RFC 8941 `sf-binary` and malformed authorities. + +```bash +# Debug a single vector with the matching public key fixture. +# Use "latest" for convenience. Pin and record exact released values for both +# versions when you need to reproduce a verifier debugging run. +VECTOR_VERSION=latest +SDK_VERSION=latest +VECTOR_DIR="$(mktemp -d)" +trap 'rm -rf "$VECTOR_DIR"' EXIT +VECTOR_BASE="https://adcontextprotocol.org/compliance/${VECTOR_VERSION}/test-vectors/request-signing" + +curl --fail --silent --show-error \ + --output "${VECTOR_DIR}/vector.json" \ + "${VECTOR_BASE}/positive/001-basic-post.json" +curl --fail --silent --show-error \ + --output "${VECTOR_DIR}/keys.json" \ + "${VECTOR_BASE}/keys.json" + +npx "@adcp/sdk@${SDK_VERSION}" signing verify-vector \ + --vector "${VECTOR_DIR}/vector.json" \ + --keys "${VECTOR_DIR}/keys.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=""`. + +The negative vectors grade `` **byte-for-byte** against the vector's `expected_outcome.error_code`, so a rejection that is correct in substance still fails its vector if the code string differs. Take the code from the taxonomy rather than from memory. + +For the codes — with retry semantics for each, and the rules for the `WWW-Authenticate` header itself — see [Transport error taxonomy](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#transport-error-taxonomy). Signing-key discovery via `brand_json_url` raises its own `request_signature_brand_*` and `request_signature_key_origin_*` codes, listed under [Discovering an agent's signing keys via `brand_json_url`](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url). + +## Related + +- [Security: Signed Requests](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/by-layer/L3/webhooks) — webhook setup including signature verification +- [Validate Your Agent](/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent) — full compliance validation including signing conformance +- [Build an Agent](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/by-layer/L1/security.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L1/security.mdx new file mode 100644 index 00000000000..0dbec250713 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L1/security.mdx @@ -0,0 +1,2808 @@ +--- +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.3.0-beta.3/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 | + +## Webhook Security + +AdCP 3.0 unifies webhook signing on the [AdCP RFC 9421 profile](#webhook-callbacks) — the seller signs outbound webhooks with a key published through its operator `brand.json` `agents[].jwks_uri`, and the buyer verifies against that JWKS. When the publisher's `adagents.json` pins `signing_keys[]` for that seller, the pin narrows the accepted keys for webhooks about that publisher's inventory; it never adds a key. 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. A buyer can ask for the legacy scheme below instead by populating the `authentication` block of a [webhook registration](#webhook-callbacks). + +### Legacy HMAC-SHA256 fallback (deprecated, removed in 4.0) + +Buyers who need to interoperate with receivers that don't verify the 9421 profile MAY ask for the legacy scheme by populating `authentication.credentials` on a [webhook registration](#webhook-callbacks). When `authentication` is present on the registration, the seller signs with HMAC-SHA256 using the semantics defined in [Push Notifications](/dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks#legacy-hmac-sha256-fallback). The legacy scheme is a 3.x-only compatibility affordance, and it is removed in AdCP 4.0. Sellers MAY decline to support it; a seller that declines rejects the registration, as [Webhook callbacks](#webhook-callbacks) describes. + +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: `. Receivers MUST ignore any deprecated body-level `signature` field when authenticating the delivery; the headers are authoritative. + +**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 the `url` of every [webhook registration](#webhook-callbacks), `accounts[].governance_agents[].url` (the seller fetches this when it calls `check_governance`), collection-list `webhook_url`, TMP provider `endpoint`, `adagents.json` `authoritative_location`, and `reporting_bucket.setup_instructions`. + +Account-level webhook subscribers registered through `sync_accounts.accounts[].notification_configs[]` and agent-level subscribers registered through `sync_principal.configuration.notification_configs[]` or `sync_agent_notification_configs.notification_configs[]` also require endpoint ownership proof before activation. SSRF validation proves the seller is not calling an internal network address; it does not prove the buyer controls the public HTTPS endpoint. Sellers MUST complete an RFC 9421-signed activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active. Account-level challenge signatures bind the account, subscriber ID, exact normalized webhook URL through the covered `@target-uri`, expiry through the signature's `expires` parameter, delivery auth metadata, and normalized event type set; the receiver MUST verify every binding before echoing the single-use nonce. Paused subscribers (`active: false`) may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and paused subscribers MUST NOT receive fires until reactivated. Account-level challenges use [`webhook-challenge.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/webhook-challenge.json) and bind `account_id`; agent-level challenges use [`agent-webhook-challenge.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/agent-webhook-challenge.json) and bind `scope: "agent"` instead of an account. + +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), except for two domain-derived well-known fetches. The initial `/.well-known/brand.json` fetch follows only between the original hostname and its exact `www` counterpart, HTTPS on the original port, for at most 3 hops. The initial `/.well-known/adagents.json` fetch follows only within the original registrable domain (eTLD+1), HTTPS-preserving, for at most 3 hops. Both exceptions are anchored on the original request and re-validate steps 1–3 on every hop. Explicit URLs — including either file's `authoritative_location`, `identity.brand_json_url`, JWKS, revocation lists, and webhook callbacks — MUST use zero redirects. +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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/governance/property/managed-networks#security-considerations) — validator fetch semantics, change detection, relationship termination. +- [TMP provider registration](/dist/docs/3.3.0-beta.3/trusted-match/specification#provider-registration-security) — dynamic registration authentication, router-to-provider auth, `/health` info-leakage rules. + +## Portable attestations + +Portable attestations let one agent reference an independently issued claim without making the presenter or AdCP the trust authority. A seller or governance agent publishes what it will evaluate through `get_adcp_capabilities.adcp.attestations`; a presenter supplies an `AttestationReference`; the evaluator resolves and verifies the credential under its own policy. + +The evaluator is always the **verifier-of-record**. A presentation is a locator and claim hint. It is not proof, a routing instruction, or permission to fetch an arbitrary URL. RFC 9421 request signing authenticates the presenting agent and protects the request in transit, but it does not prove the presented claim. + +### Data model and separation + +Implementations MUST keep three layers distinct: + +1. **Presentation** — the portable reference supplied by a buyer or another agent. +2. **Credential** — the issuer-signed claim found through the reference or supplied as an embedded proof. +3. **Evaluation** — the receiving seller's or governance agent's result after applying its trust policy. + +{/* Using latest because these schemas are not yet released in any version. + Update to the correct version alias after the next release. */} +The shared schemas are [`attestation-reference.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/attestation-reference.json), [`attestation-issuer.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/attestation-issuer.json), [`attestation-subject.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/attestation-subject.json), and [`attestation-evaluation.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/attestation-evaluation.json). + +Every presentation identifies an issuer, an open URI-namespaced `claim_type`, a typed subject, and at least one delivery path: + +| Path | Presentation fields | Evaluator behavior | +|---|---|---| +| HTTPS credential | `locator.type: credential_uri` + `credential_uri` | Match the URI's canonical origin to the accepted issuer's `credential_origins` before DNS resolution or any request. | +| Issuer credential ID | `locator.type: issuer_credential_id` + `credential_id` + `resolver_id` | Resolve `resolver_id` to a URL from the evaluator's own capability configuration, then send the fixed resolver request defined below. The presentation never carries the resolver URL. | +| Embedded credential | `embedded_credential.format` + `embedded_credential.value` | Enforce the advertised byte limit before parsing, then verify the supported format locally or through an accepted verifier. | + +A presentation MAY carry both a locator and an embedded credential. In that case `content_digest` is REQUIRED, the evaluator MUST obtain both representations, and the exact bytes of each MUST independently match that digest before either is trusted. It MUST verify both credentials under the same proof-format and issuer rules. A missing, unverifiable, or mismatched representation is a hard failure; the evaluator MUST NOT select the representation that passes or fall back to an unpinned body. `credential_version` and `validity_hint` are planning hints only; the credential is authoritative for version, validity, scope, and revocation. + +For a resource subject, `subject.content_digest` optionally pins the content or immutable snapshot the credential is about. It is part of the complete typed subject comparison. It is distinct from the presentation-level `content_digest`, which pins credential bytes. + +Issuer and claim identifiers are open vocabularies. An evaluator listing them expresses only its own policy; it is not an AgenticAdvertising.org endorsement or a global trust registry. + +The presenter MAY nominate `verify_agent.agent_url`, but it MUST match `accepted_verifiers[]` after [URL canonicalization](/dist/docs/3.3.0-beta.3/reference/url-canonicalization). The evaluator MAY use that verifier, another accepted verifier, or local verification. It MUST NOT call an off-list presenter-supplied endpoint. + +### Normative evaluation procedure + +For every presentation, an evaluator MUST perform these steps in order: + +1. **Validate without network access.** Reject malformed issuer, subject, locator, proof format, and digest fields. JSON inputs MUST be strict-parsed with duplicate object keys rejected before JCS, digest computation, schema validation, or field extraction; a last-key-wins or first-key-wins parser is insufficient. +2. **Apply the capability allowlist.** Match the canonical issuer, claim type, delivery method, proof format when known, and nominated verifier. Reject an untrusted issuer, resolver, credential origin, or verifier without an outbound request. +3. **Select delivery.** For `credential_uri`, validate the canonical origin against the issuer capability. For `issuer_credential_id`, map `resolver_id` through that capability. For embedded delivery, enforce `max_embedded_credential_bytes` before parsing. +4. **Resolve safely when needed.** Apply the fetch contract below to the evaluator-selected URL. +5. **Verify the credential deterministically.** Confirm proof/signature integrity, issuer identity, claim type, complete typed subject, claim scope, validity period, and revocation state. Verify `content_digest` before using credential contents. Cryptographic verification, issuer-key authorization, canonicalization, equality checks, and outcome selection MUST run in deterministic code rather than an LLM. Credential bytes and resolver responses MUST NOT be placed in an LLM context before these checks; an LLM MAY assist a later policy workflow but cannot establish a `verified` outcome. +6. **Apply local trust policy.** A cryptographically valid credential may still be unacceptable for this claim, subject, jurisdiction, action, or time. +7. **Emit a stable outcome.** Never convert resolution or policy failure into success merely because the presenter asserted the claim. +8. **Bind the result.** Carry the exact presentation digest and action binding in the domain result or readback that relied on it. + +### Attestation resolution fetch contract + +For `issuer_credential_id`, the resolver transport is fixed: `POST` to the evaluator-configured resolver URL with `Content-Type: application/json` and a strict JSON body whose only member is `{"credential_id":""}`. The credential ID MUST NOT be interpolated into the URL path or query. Credential-URI fetches carry no Authorization header or cookies. Resolver authentication, when configured as `evaluator_managed`, is loaded by the evaluator and sent only to the exact configured resolver endpoint; credentials from the incoming task, a credential response, or another origin MUST NOT be forwarded. + +Before every credential or resolver request, evaluators MUST: + +1. Require HTTPS and reject userinfo, fragments, and non-HTTPS schemes. +2. Match the canonical credential origin or configured resolver ID to `accepted_issuers[]` before DNS resolution. +3. Resolve the hostname and reject private, loopback, link-local, unique-local, CGNAT, multicast, special-use, and cloud-metadata destinations, including IPv4-mapped IPv6 forms. +4. Pin the connection to the validated address, or validate the post-TLS peer address before sending request bytes, to prevent DNS rebinding. +5. Disable redirects and treat every 3xx response as `resolution_failed`. The redirect limit is zero. Authentication headers and cookies MUST NOT be forwarded to any other endpoint. +6. Apply a connect timeout of at most 10 seconds, a read timeout of at most 10 seconds, and a streamed response-body cap of 1 MiB. +7. Keep resolver credentials outside AdCP task payloads and LLM-visible arguments. `authentication: evaluator_managed` means the evaluator loads them from its own secret store. +8. Avoid returning connection-detail differences to the presenter. Map network details to stable outcomes without exposing internal topology. + +Before parsing a task that can carry portable attestations, implementations MUST enforce an overall task-body cap of 4 MiB, a maximum JSON nesting depth of 64, and at most 100,000 aggregate object members and array elements. These are whole-task caps, not per-attestation allowances, and apply in addition to the advertised per-credential `max_embedded_credential_bytes` (whose protocol ceiling is 1 MiB). Reject an over-limit body before schema evaluation, JCS, credential extraction, logging of body content, or any LLM-visible processing. An evaluator that advertises `embedded` delivery MUST advertise and enforce `max_embedded_credential_bytes`. + +These rules extend the general [SSRF profile](#webhook-url-validation-ssrf). A URL is never authorized solely because it appeared in a signed request. + +Resolver access can disclose which buyer, signal, campaign, or claim is being evaluated. Evaluators SHOULD prefer privacy-preserving resolution, send no unnecessary campaign identifiers, disclose resolver access to their operators and users, and bound logs and caches accordingly. + +### Issuer signing-key authority + +A mathematically valid signature does not by itself establish the `AttestationIssuer` named by the presentation or credential. For each accepted proof format, the evaluator MUST authorize the verification key for that canonical issuer through either (a) the proof format's authenticated issuer-key discovery and binding procedure or (b) an evaluator-managed local trust anchor configured for that issuer and proof format. Unauthenticated key URLs, presenter-supplied keys, and a credential's bare `kid` value are not trust anchors. A credential signed correctly by a key that is not authorized for its claimed issuer has outcome `invalid`, even if the key is trusted for another issuer. + +Key discovery is itself part of the proof-format verifier and MUST apply that format's transport, chain, algorithm, key-use, revocation, and freshness requirements. The evaluator MUST bind cached keys to canonical issuer identity and proof format; it MUST NOT reuse a same-named `kid` across issuers. + +### Outcomes, action binding, and caching + +Stable outcomes are `verified`, `not_found`, `expired`, `revoked`, `invalid`, `unsupported`, `unverifiable`, `untrusted_issuer`, `untrusted_resolver`, `subject_mismatch`, `digest_mismatch`, and `resolution_failed`. Only `verified` means all required credential and policy checks passed. + +Every `AttestationEvaluation` requires `reference_digest`, computed as SHA-256 over the RFC 8785 JSON Canonicalization Scheme representation of the complete `AttestationReference`, including an embedded credential. Duplicate JSON object keys MUST already have been rejected before canonicalization. Outcomes based on obtained credential bytes also carry `credential_digest` and `proof_format`, so the digest's byte interpretation is explicit. For dual delivery, `credential_digest` records the resolved bytes while `reference_digest` independently binds the embedded bytes; both representations have already matched the required presentation digest. A domain consumer that relies on the result MUST carry `action_binding` with a domain-defined action type and either an action identifier or canonical action digest. + +This produces two independent bindings: `reference_digest` identifies exactly what was evaluated; `action_binding` identifies the governance decision, evidence snapshot, rights decision, or other action that relied on it. Portable credentials SHOULD remain reusable across sellers unless the issuer's model requires a specific audience; the transaction binding belongs to the evaluation result rather than automatically inside every credential. + +Evaluators MAY cache results, but cache keys MUST include canonical issuer identity, locator, proof format, and credential or reference digest. Cache lifetime MUST be bounded by the earliest of credential expiry, issuer/verifier key refresh, revocation freshness policy, evaluator policy, and `valid_until`. Trust-policy or revocation changes invalidate prior success. + +### Rights-grant attestation profile (3.2) + +Rights constraints compose the shared portable-attestation primitive; they do not define a rights-only JWS or trust store. The buyer carries `rights_constraint.attestation_refs[]`, the serving seller remains verifier-of-record, and only the seller produces [`RightsAttestationEvaluation`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/rights-attestation-evaluation.json) readback. A buyer-authored evaluation or the mere presence of a reference has no authorization value. + +The rights profile uses these identifiers: + +- claim type: `https://adcontextprotocol.org/claims/rights/grant` +- resource subject type: `https://adcontextprotocol.org/claims/subjects/rights-grant` +- action type: `https://adcontextprotocol.org/actions/rights-grant-evaluation` + +Each reference MUST use a `brand` issuer exactly equal to `rights_holder`. After URL canonicalization, `subject.namespace` MUST equal `rights_constraint.rights_agent.url`; `subject.id` MUST equal `rights_id`; and `subject.content_digest` MUST equal the constraint's `content_digest`. The digest is `sha256:` plus lowercase-hex SHA-256 of `UTF8(JCS(rights_constraint minus { content_digest, attestation_refs, verification_url }))`. Omitting an optional member is distinct from supplying an empty value. The projection deliberately excludes `verification_url`, which is a deprecated legacy display hint with no fetch or authorization semantics, and includes `ext` plus future grant fields so new serving restrictions cannot be added outside the signed projection. + +For AdCP 3.2, every acquired `acquire_rights` response and every reissued `update_rights` constraint MUST carry `content_digest` plus at least one `attestation_refs` entry. The schema keeps them optional only so a 3.2 parser can ingest 3.1 constraints; absence means machine-unverified/informational, never authorized. Multiple references are alternative delivery or proof paths—one current `verified` result is sufficient unless evaluator policy requires more. + +The resolved or embedded credential MUST cryptographically protect the exact issuer, claim type, complete typed subject, grant version, validity, and credential identifier. A `verified` result additionally requires authenticated issuer-bound status evidence for that exact credential and grant, an approved constraint, current credential and grant validity, exact digest agreement, and evaluator policy accepting the issuer, proof format, delivery method, jurisdiction, and consuming action. The evaluator MUST bind the actual status-proof verification key through the same exact holder rights-agent JWKS and `attestation-signing` purpose as the credential key; signature validity without that authority check is insufficient. Verified evaluations MUST carry `revocation_checked_at` and `valid_until`; the latter is the earliest credential/grant expiry, revocation-freshness bound, key-refresh bound, and local-policy bound. Missing, stale, swapped, superseded, or unverifiable status cannot produce `verified`. A seller still checks the actual use, country, time, and impression-cap ledger; a grant evaluation cannot prove remaining cap. Partial revocation revokes the old credential and issues a new narrowed constraint/reference rather than leaving the old broad credential valid. + +For 3.2 evaluation, `rights_holder` is mandatory and issuer-key authorization chains to that exact BrandRef. Before DNS or any fetch, the evaluator MUST exact-match the holder against its accepted issuer policy; an off-policy holder fails with zero network access. Resolve `rights_holder.domain` using the standard SSRF-safe brand.json contract: HTTPS only, reserved/private/link-local IP rejection before and after DNS, DNS pinning, bounded redirects, a brand.json body cap of 2 MiB enforced while the body streams (see [brand.json fetch budget](#discovering-an-agents-signing-keys-via-brand_json_url), step 4), deadlines, and negative caching. When `brand_id` is present, select exactly one `brands[]` entry whose `id` equals it; use that entry's `agents[]` when present, otherwise the house `agents[]` fallback. When `brand_id` is absent, the document must unambiguously represent the single brand or house. This is [Agent resolution](#agent-resolution) with the holder's brand.json as the relying-party record. Select exactly one applicable entry with `type: "rights"`, canonical `url == rights_agent.url`, and exact `id == rights_agent.id`. Never aggregate sibling-brand agents or discover a key from a credential-supplied URL. Resolve JWKS only from the selected entry and bind the actual credential verification key by JWK thumbprint, not `kid` alone. AdCP-agent credential keys use the shared portable-credential purpose `adcp_use: "attestation-signing"`; this is not a rights-only purpose. `response-signing` remains reserved for designated brand-response payloads. + +Every seller-produced rights evaluation MUST bind both `action_id == rights_id` and `action_digest == content_digest` under the rights action type and MUST satisfy `reference_digest == sha256(JCS(reference))`. It MUST have `outcome: verified`, and the seller MUST retrieve the byte-identical result from its own local evaluation store keyed by `(evaluated_by, reference_digest)` before relying on it. The `evaluated_by` string is descriptive, not provenance: buyer-authored, foreign-seller, absent-store, and locally mismatched readback all fail closed. This is a reusable evaluation of one exact rights grant, not a serving authorization. The same evaluator may reuse its locally persisted result for another creative carrying the identical unchanged grant until `evaluation.valid_until`; another seller never trusts that readback as its own evaluation. Creative identity, media buy, use, country, delivery scope, and cap state are separate eligibility inputs and MUST be checked at the consuming boundary. The seller persists the exact reference/evaluation pair in creative readback and re-evaluates no later than `evaluation.valid_until`. + +Sellers advertise this behavior with `media_buy.rights_attestations.requirement`. Presence requires `adcp.attestations` and the exact rights claim URI in `accepted_claim_types`. `required` makes a current verified result a serving-eligibility condition. `optional` permits a separately established legacy contractual path, but the constraint remains machine-unverified and implementations MUST NOT downgrade to `verification_url`. + +Versioned fixtures under `/compliance/{version}/test-vectors/attestations/` cover resolved and embedded credentials, off-policy issuers and resolvers, expiry and revocation, subject and digest mismatch, and resolution failure. Implementations must produce the expected outcome and perform no network access for off-policy cases. + +## 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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/reference/glossary#a) and [Agent](/dist/docs/3.3.0-beta.3/reference/glossary#a). + +### The two-step pattern + +Every account-scoped request whose schema requires `account` carries an explicit `AccountRef` (via `account_id` for account-id namespaces, or the `{brand, operator}` natural key for buyer-declared accounts). A seller MUST NOT silently replace a missing required `account` with a credential-implied default. For tasks where `account` is optional, omission semantics are task-local and must be documented by that task. Correct isolation is two checks, performed in order: + +1. **Auth precheck** — the request's `account` MUST be in the authenticated agent's authorized set. Fail closed with a 403 or a generic "not found" (never "you are not authorized for that account" — that's an existence leak). +2. **Resource query** — filter by the request's `account_id` as the primary key constraint. Not by the whole authorized set — only by the specific account this request is acting on. + +```javascript +// 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 use either `inventory_local` or a concrete IANA timezone identifier. `inventory_local` evaluates the window in the seller-assigned local timezone of each inventory unit that can deliver the impression; it never means the buyer, account, or server timezone. A concrete IANA identifier evaluates one shared civil-time clock across all targeted inventory. Omission defaults to `inventory_local`; there are no user-, account-, or viewer-timezone aliases on the wire. Entries are independent and may use different clocks. + +Sellers MUST validate concrete identifiers against their supported IANA timezone database and MUST NOT silently reinterpret a requested clock. They evaluate each candidate instant after conversion to the selected civil clock, so skipped DST hours match nothing and repeated hours match both occurrences. An unknown identifier is `INVALID_REQUEST`; a valid timezone outside the product's declared support is `UNSUPPORTED_FEATURE` at create or update time. The product-scoped capability rules are defined in [Targeting](/dist/docs/3.3.0-beta.3/media-buy/advanced-topics/targeting#daypart-timezone-semantics). + +Experimental `start_time`/`end_time` windows use the same clock and half-open rule at `HH:MM` resolution. An `end_time` earlier than `start_time` crosses midnight and belongs to the day it starts on, and a seller MUST NOT round a clock time finer than the product's declared `time_granularity`; it rejects the entry with `UNSUPPORTED_FEATURE`. See [Minute-resolution dayparts](/dist/docs/3.3.0-beta.3/media-buy/advanced-topics/targeting#minute-resolution-dayparts-experimental). + +## Request Safety + +### Idempotency + +`idempotency_key` is **required on every state-mutating AdCP task request**, except the published 3.x `get_products` compatibility facade, whose request shape keeps the field optional. Guaranteed pure-read tasks leave it optional. Their wrappers MUST tolerate the field when a caller supplies one, but sellers MAY ignore it and execute the read normally; buyers MUST NOT rely on a keyed read returning a byte-stable replay. A seller that voluntarily applies replay semantics to a pure read MUST follow the complete contract below and document that behavior, including its freshness implications. Keys participating in replay 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. + +**Product-discovery migration in 3.2.** AdCP 3.2 adds four compact tools alongside the stable `get_products` facade: + +- `list_products` is a naturally idempotent synchronous read. Its key is optional, and a seller MAY ignore one when supplied. +- `request_proposals`, `refine_proposals`, and `decline_proposals` require a key because they can allocate tasks or change proposal state. +- `get_products` remains valid throughout 3.x as a deprecated compatibility facade, and its published request schema keeps the key optional on every arm. A seller MAY ignore a supplied key on a guaranteed side-effect-free synchronous read. Buyers SHOULD supply a key whenever the request may allocate a task, finalize a proposal, or otherwise change observable state. When supplied and the seller declares `adcp.idempotency.supported: true`, the seller MUST apply the replay contract before that effect. If the key is omitted or the seller declares `adcp.idempotency.supported: false`, the buyer has no portable at-most-once retry guarantee after an ambiguous result. + +The compact tools reuse legacy implementation paths where useful but are distinct logical operations. A retry MUST keep the same tool name and payload. Reusing a key on another tool is a different payload and returns `IDEMPOTENCY_CONFLICT`; callers must not switch names after an ambiguous failure. + +`request_proposals` binds each opaque proposal ID to its brand (`domain` and optional `brand_id`) and, when supplied, its resolved seller `account_id`. Distinct accounts therefore do not share an idempotency or proposal namespace. Account attributes such as `brand.countries` are not part of the binding. `refine_proposals` and `decline_proposals` resolve that binding before idempotency lookup, require every resolvable ID in a batch to share one binding, and scope the key to the resolved account or BrandKey. Callers do not repeat identity data on those mutation requests, and sellers MUST return the same not-found response for an absent proposal and a proposal outside the caller's principal/account scope. When no ID in a batch resolves, sellers use a principal-local negative-lookup scope; this permits safe replay of an all-`unable` result without creating a cross-account oracle. + +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 + +Rules 1–10 apply to state-mutating requests and to any optional-key request for which the seller elects or is required to provide replay. A pure read whose optional key is ignored does not create an idempotency-cache entry and is not subject to replay, conflict, expiry, or durability behavior. + +1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key` when declared) 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 3). +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. + + **Resource deletion does not end the replay window.** The idempotency key record, canonical request hash, and cached response MUST remain available independently of every resource the request created for the full `replay_ttl_seconds`, measured from the successful mutation's durable commit time (`committed_at`). Eviction MUST occur no earlier than `committed_at + replay_ttl_seconds`, even when an operator or later task independently deletes or purges that resource. A `UNIQUE` constraint on an `idempotency_key` column of the resource row alone does not satisfy this requirement: deleting the row would free the key and permit a retry to create a second resource. A replay after resource deletion still returns the historical cached response under rule 4; the buyer then uses the appropriate read task to observe that the resource is gone. +3. **Only unsuccessful operations release their keys.** On any error before a mutation commits — 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. An error whose outcome is ambiguous is not released here; rule 9 fences its key. A seller declaring AdCP 3.3 or later SHOULD mark that error with [`mutation_outcome`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling#mutation-outcome) `unknown` (or `applied`; a MUST from 3.4), so the buyer can tell it from a pre-commit rejection that released its key. + + `COMMITTED_RESOURCE_PURGED` is the narrow exception to the ordinary error-response rule because it reports a successful write followed by an independent deletion, not an unsuccessful operation or a requested delete operation. When a mutation commits but its affected resource is independently deleted or purged before the seller can durably record the canonical success response, the seller MUST retain a committed-outcome tombstone and canonical request hash until at least `committed_at + replay_ttl_seconds`, return `COMMITTED_RESOURCE_PURGED` with `recovery: terminal` and `mutation_outcome: "applied"` (sellers MUST NOT set `not_applied` on it), and return the same terminal outcome with `replayed: true` on equivalent replays without re-executing the mutation. If the canonical success response was already durably recorded, rule 4 takes precedence and the seller returns that historical response even though a current-state read shows the resource is gone. The tombstone MUST be stored independently of the deleted resource. Buyers MUST NOT automatically retry that logical operation with either the same or a fresh key; they MUST first reconcile through the resource read task and seller audit or operator records, and may initiate a new operation only when reconciliation confirms that is intended. Sellers MUST NOT use `CONFLICT` for this case: its transient recovery would instruct the buyer to retry a write that already committed. +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 — including when replaying a committed-outcome tombstone — and `replayed` is NOT part of the stored inner response or tombstone. Injection at replay time keeps the stored 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 `committed_at + replay_ttl_seconds` the seller MAY evict a completed cache entry or committed-outcome tombstone. An unresolved in-flight or reconciliation-required claim is not a completed entry and MUST NOT be evicted merely because this interval elapsed. 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.3.0-beta.3/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+):** 60 mutation-cache inserts/sec sustained and 300/sec burst over a rolling 10-second window per `(authenticated_agent, account)`. This starting point is sized against a high-volume launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). Operators with steady low-volume traffic MAY tighten below it; operators with larger legitimate bursts MUST raise it rather than silently reject valid mutations. The numeric ceiling is a SHOULD; the rate-limit-and-reject-with-`RATE_LIMITED` behavior is a MUST. + + Sellers that voluntarily cache keyed reads MUST give them a separate, seller-defined insert budget so read polling cannot exhaust mutation replay capacity. They MUST document the read budget and window shape, but need not advertise exact ceiling numerics in capability responses. Buyers discover effective ceilings through `RATE_LIMITED` plus `retry_after`. + + 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.3.0-beta.3/building/by-layer/L3/error-handling#rate-limit-handling) and MUST NOT be cached as idempotency responses (rule 3: only successful responses are cached). Sellers SHOULD enforce `retry_after` as a cheap rejection floor — a buyer retrying before `retry_after` elapses SHOULD hit a pre-auth token bucket (e.g., at a reverse-proxy layer) rather than re-entering the full schema-validate-and-cache-check pipeline on every retry. Without this discipline, misbehaving buyers can amplify load on the rate-limiter itself. + +9. **Concurrent retries — first-insert-wins.** A second request carrying the same `(authenticated_agent, account_id, idempotency_key)` MAY arrive while the first request is still executing — most commonly when the buyer's transport timeout fires before the seller's downstream call returns, and the buyer retries. Sellers MUST resolve the race deterministically; they MUST NOT execute the side effect twice and MUST NOT silently drop the second request. Resolution is a `(unique constraint, INSERT … ON CONFLICT DO NOTHING)` pattern on the scope tuple: the first row to land owns execution and stores the canonical payload hash on the in-flight row (NOT a sentinel); subsequent requests observe an existing row whose response slot is not yet populated but whose payload hash IS populated. + + Sellers MUST handle the second request by one of two policies and MUST behave consistently across calls — clients infer the policy from the first response within a session and apply it to subsequent retries: + + - **Wait-and-replay** (preferred for fast operations, <5s typical): the seller blocks the second request until the first completes, then returns the cached response with `replayed: true`. Total wall-time for the second call is bounded by the seller's request-timeout budget. + - **Reject-and-redirect** (preferred for slow operations involving long-running downstream calls): the seller returns `IDEMPOTENCY_IN_FLIGHT` immediately, with top-level `error.retry_after` (seconds) populated based on the first request's elapsed time and expected completion. Buyers MUST retry with the same `idempotency_key` after the hint elapses — a buyer that mints a fresh key on `IDEMPOTENCY_IN_FLIGHT` turns a safe retry into the exact double-execution race this rule prevents. + + A second request with the same key AND a *different* canonical payload during the in-flight window MUST return `IDEMPOTENCY_CONFLICT` (rule 5), not `IDEMPOTENCY_IN_FLIGHT` — the canonical-form mismatch is computable at INSERT time against the row's stored hash, so the conflict is detectable without waiting for the first request's response. Sellers whose backing store cannot persist the real canonical hash until the handler completes (e.g., a placeholder-sentinel pattern) MUST upgrade the store to persist the hash at INSERT time before declaring rule 9 conformance — the alternative (returning `IDEMPOTENCY_IN_FLIGHT` on a same-key-different-payload race and only surfacing the conflict after the first request completes) silently delays detection of a real client bug. + + Per rule 3, the seller may release the `(in_flight)` claim only after it has definitive evidence that no local or downstream mutation committed. Schema rejection and errors raised before any side-effecting call are definitive pre-commit failures. A downstream timeout, handler timeout, lost response, process crash, or any other unknown outcome is not proof of failure: the seller MUST retain the durable claim, transition it from active execution to a reconciliation-required state, and MUST NOT invoke the mutation again until reconciliation establishes the outcome. Equivalent retries MAY wait, receive `IDEMPOTENCY_IN_FLIGHT`, or drive the rule-10 reconciliation path; a different canonical hash still returns `IDEMPOTENCY_CONFLICT`. A seller declaring AdCP 3.3 or later SHOULD return `mutation_outcome: "unknown"` (or `applied` when it holds committed evidence) on the error for a fenced claim; this becomes a MUST in 3.4. Releasing a claim does not by itself license `not_applied`: that value needs the stricter [pre-commit evidence](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling#mutation-outcome) test, under which a timeout or exception from within or after a write is never enough. When reconciliation proves non-commit, the seller releases the claim; when it proves commit, the seller records the canonical success response or, after an independent purge, the `COMMITTED_RESOURCE_PURGED` tombstone. Whenever the resource write and idempotency ledger share a transactional boundary, the seller MUST atomically commit the mutation and completed ledger state. Implementations that cannot make that transition atomic MUST use the same fail-closed reconciliation discipline. + + Sellers MUST bound the active execution lease to the declared per-task handler timeout so a hung worker can be stopped or replaced. Lease expiry changes the claim to reconciliation-required; it never releases or evicts the claim. Sellers using reject-and-redirect MUST set top-level `error.retry_after` to a value no greater than `replay_ttl_seconds` (declared in `capabilities.idempotency`). Sellers SHOULD also declare `capabilities.idempotency.in_flight_max_seconds` — the maximum active execution lease before reconciliation takes over, scoped to the seller's per-task handler timeout. Buyers SHOULD use that declared value as the primary bound for an individual retry wait 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). This value is not permission for a buyer to mint a fresh key or for a seller to release an unresolved claim. + + 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 proven pre-commit failures release their claims; completed outcomes become cached successes or committed-outcome tombstones) is necessary but not sufficient: a seller that releases an ambiguous claim 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 and generic transport wrappers may send envelope-level fields admitted by the selected request schema, either as declared properties or through `additionalProperties: true`. Reference SDKs auto-generate `idempotency_key` only for requests they classify as mutating. Servers MUST tolerate schema-admitted fields even when the task handler does not consume them. An explicitly closed schema may reject undeclared fields. Concretely: + +- **`idempotency_key`** is required on state-mutating task schemas. Guaranteed pure-read schemas and the 3.x `get_products` compatibility facade may leave it optional, but tool wrappers MUST accept it when the selected schema admits it. A pure-read handler MAY ignore it; a request participating in replay routes it through rules 1–10. +- **`context`, `push_notification_config`, and `governance_context`** MUST be accepted when the selected schema admits them. Tools that do not consume an admitted field MUST ignore it. +- **`context_id` on MCP**, where admitted by the selected request schema, MUST be ignored. It MUST NOT select session state, identity, account, authorization, task continuation, or idempotency scope. A2A conversation continuity uses native Message/Task `contextId`, outside the AdCP task body. + +Configuring a server-side validator in a way that contradicts the selected schema's declared properties or `additionalProperties` setting 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 admitted envelope fields as accept-and-ignore optional parameters, or use a `**kwargs` catch-all and discard unknown keys where the selected schema permits them. 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 every field admitted by the selected request schema, regardless of whether the task handler consumes it. Request-body `context_id` follows the compatibility rule above. This rule is normative for 3.1+; wrappers that reject schema-admitted fields are non-conformant. + +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 + +Whenever a buyer sends an `idempotency_key`, it MUST generate a unique 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 keyed request. For a state-mutating request, or a read that the seller documents as replay-protected, a retry after a network error 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. A pure read has no portable replay guarantee even if its wrapper accepted a key; retry it as a fresh read unless the seller explicitly documents stronger behavior. + +**Network retry vs. agent re-plan vs. polling / state re-read.** Three cases that look similar but need different handling: + +- **Network retry of a replay-protected operation** — 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. An ordinary pure read is instead retried as a fresh read because the seller may have ignored its key. +- **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`, or `list_accounts`; a buyer reading `get_media_buys` after a mutation; any "give me current state at time T" call. Pure reads do not provide a portable keyed-replay guarantee, so buyers SHOULD omit `idempotency_key` and treat each invocation as a fresh read. If a generic wrapper supplies one, mint a fresh key for each poll because some sellers may voluntarily cache keyed reads. Never reuse the mutation's key on its readback request. + +For replay-protected operations, ask whether the buyer's intent is **"complete the same operation as before"** (network retry — reuse the key) or **"give me the current answer"** (polling / state re-read — omit the key, or mint a new one if a generic wrapper requires it) or **"do this new thing"** (agent re-plan — mint a new key). Agentic clients that loop through an LLM to build a mutation 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. + +**Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from any schema-level key requirement. `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`, and sellers MUST accept the call without it. A supplied key may be ignored and does not create a portable replay guarantee. The fail-closed obligation below applies to retry-sensitive mutations 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 before any state-mutating task — including `request_proposals`, `refine_proposals`, and `decline_proposals` — so the buyer learns about the non-compliance before a silent duplicate. A guaranteed pure-read task whose schema leaves the key optional MAY proceed without replay protection, but the client MUST NOT silently treat a retry as deduplicated. + +**Failed mutations with an uncertain outcome.** `recovery: transient` says a retry may succeed; it does not say the request had no effect. When an error for a state-changing request carries [`mutation_outcome`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling#mutation-outcome) `unknown`, `applied`, or an unrecognized value, buyers MUST NOT mint a new `idempotency_key`, change the payload, report the operation as definitively failed, or undo their own record of the change on the strength of that error alone, and MUST read current state before any re-plan. This applies equally to the terminal error of an async task that ends in `failed`, however delivered. A same-bytes, same-key resend remains correct. When the field is absent on a `transient` error, buyers SHOULD read current state before a re-plan; that is a SHOULD, costs one read, and is safe against every seller. + +**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.3.0-beta.3/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.3.0-beta.3/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. + +**A re-read is a new request.** Do not reuse the mutation's `idempotency_key` on its readback request. Pure-read callers SHOULD omit the field; generic wrappers that include it SHOULD mint a new key for each poll because keyed-read caching is optional seller behavior and cannot be assumed either present or absent. + +**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.** When sellers cache keyed read-tool responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`, etc.), those entries join the write receipts already held by the cache. Read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller's underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by `(authenticated_agent, account_id)` at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant's cached read response. + +**Keys MUST be unguessable.** Schema enforces `^[A-Za-z0-9_.:-]{16,255}$` and buyers MUST use UUID v4 (~122 bits of entropy) or an equivalent CSPRNG-generated value. Low-entropy keys like `retry-001` or monotonic counters turn the cache into an enumerable surface: an attacker can walk the key space and test each one against a target agent. Sellers SHOULD reject keys that fail a basic entropy check (e.g., all-zeros, repeated characters, short ASCII words) with `INVALID_REQUEST` when the authenticated agent is not individually trusted. + +**The three-state response (`success` / `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_EXPIRED`) is an existence oracle for idempotency keys.** An attacker who holds a candidate key can probe it: `success` means never seen, `IDEMPOTENCY_CONFLICT` means live with a different payload, `IDEMPOTENCY_EXPIRED` means previously used. The per-`(agent, account)` scoping above is the primary defense — an attacker authenticated as agent A cannot probe agent B's keys, and a caller scoped to account A cannot probe account B's keys even under a shared agent credential. Unguessable keys are the secondary defense — an attacker who cannot guess a victim's key cannot probe the oracle usefully. Sellers MUST NOT surface `IDEMPOTENCY_EXPIRED` across scope boundaries or to unauthenticated callers. Sellers SHOULD also avoid distinguishable timing between "key exists" and "key does not exist" lookups in the idempotency layer; a constant-time floor on the negative path closes a side channel that persists even without an error-code oracle. + +**SI session scope.** For `si_send_message` the key is scoped `(authenticated_agent, account_id, session_id, idempotency_key)`. `session_id` is therefore part of the oracle surface: if session IDs are guessable, an attacker who steals one key can probe it against many sessions. SI sellers MUST generate `session_id` server-side using a CSPRNG with ≥122 bits of entropy (UUID v4 or equivalent) and MUST NOT derive it from anything observable to another agent (request sequence number, user handle, timestamps). The same idempotency_key sent with a different `session_id` is a different scope tuple — always a new request, never a conflict. + +**`account_id` entropy for cache-scope safety.** `account_id` is part of every idempotency scope tuple, so it is also part of the oracle surface: an attacker authenticated as agent A with a stolen idempotency key could probe it against candidate account IDs to enumerate accounts in A's authorized set or learn which accounts A has ever operated on. When account IDs are short sequential or semantic values (`acct_123`, `nike-us`), this is a real enumeration channel. Sellers that issue server-assigned account IDs MUST use unguessable values (UUID v4 / ULID, ≥122 bits of entropy) for any account ID that participates in an idempotency cache scope. Sellers operating under the buyer-declared account model (natural-key `{brand, operator}`) MUST hash the natural key with a seller-local salt before using it as a cache-scope component — the natural key is public by design and cannot be used directly as an oracle defense. + +```javascript +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. +- **Services** persist the token as received and include it verbatim on all subsequent governance calls. A service advertising `adcp.governance_enforcement` for a task MUST verify per the checklist below before acting. Legacy forward-only services may retain and forward tokens for audit continuity, but MUST NOT advertise enforcement for that task. +- **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. Services parse only the standard claims required by the verification checklist, including task, payload-hash, and commitment bindings; they never interpret `sub`, `plan_hash`, or other embedded governance state as business data. + +#### Scope and dependencies + +- **In scope (3.0)**: buy-side governance. The `governance_context` token authorizes governed commitments made via AdCP tasks whose request schemas carry an `x-governed-commitment` annotation. A service's own compliance or commercial rejection remains separate from the buyer-governance decision. +- **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 for authorization-changing profile extensions. Per RFC 7515 §4.1.11, `crit` lists protected-header parameter names that MUST be understood by the verifier. Claims `authorized_commitment`, `authorized_task`, and `authorized_payload_hash` each require a same-named boolean protected-header marker and `crit` entry. Verifiers MUST recognize and process every listed extension or reject the token. This prevents older verifiers from silently ignoring a ceiling or action constraint. + +**Claims** + +| Claim | Required | Description | +|-------|----------|-------------| +| `iss` | Yes | Governance agent identifier. MUST be an HTTPS URL that matches the `url` of exactly one governance-typed entry in the buyer's brand.json under the shared [URL canonicalization](/dist/docs/3.3.0-beta.3/reference/url-canonicalization) rules, per [Agent resolution](#agent-resolution). The path is significant, 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 | Issuer-generated opaque governance binding for this governed action. It MUST be stable across that action's lifecycle, unique per action and target audience, and MUST NOT contain or be derived reversibly from `plan_id`. The governance agent maps it to the internal plan; services compare it only for lifecycle continuity and never interpret it as a plan or user identifier. | +| `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.3.0-beta.3/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`, `purchase`, `modification`, or `delivery`. It identifies the authorization stage, not merely the task name. A buyer-supplied token entering any governed service request is `intent`. When the service advertises `online_execution_check`, the service then obtains `purchase` before committing a create, `modification` before committing an update, or `delivery` for a delivery check. | +| `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 `modification` and `delivery` tokens, MAY be present on `purchase` when the service provisionally assigns an ID during prepare, and MUST be absent on `intent`. The opaque `sub` binds a purchase action before a durable media-buy ID exists. | +| `authorized_commitment` | Conditional | Signed `{ amount, currency }` ceiling approved by governance. REQUIRED whenever the check authorizes a numeric monetary commitment, including an explicit `{amount: 0, currency}` ceiling for a verified zero-cost action; omitted only for non-monetary actions. Its matching protected-header marker is critical as defined above. The receiving service compares the price it will commit with this claim, so an indirectly priced payload cannot be authorized with an understated buyer declaration. This reveals only the price already relevant to that service, not plan budget or allocation. | +| `authorized_task` | Conditional | Exact AdCP task name authorized by an intent check. REQUIRED with `authorized_payload_hash` on action-authorizing tokens and listed in `crit`. A service rejects a token minted for another task even when audience and monetary ceiling match. | +| `authorized_payload_hash` | Conditional | `base64url_no_pad(SHA-256(JCS(payload_without_governance_metadata)))`, REQUIRED with `authorized_task` and listed in `crit`. The excluded top-level fields are exactly `governance_context` and `context`; `target_agent` lives on the governance check rather than in the downstream payload. Every business field, including `idempotency_key`, remains bound. The service recomputes this value over the received task request before committing. | +| `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). | +| `runtime_attestation_binding_digest` | Conditional | Required when the check evaluated `runtime_attestations[]`. MUST byte-equal the check response field of the same name: `sha256:` plus lowercase-hex SHA-256 of `JCS({ evaluations, findings })`, where `evaluations` is the ordered `runtime_attestation_evaluations[]` array and `findings` is the ordered response subset carrying `attestation_reference_digest`. This binds the exact credential/reference digests, normalized outcomes, confidence-bearing findings, and governance-check action binding without putting credential bodies in the token. Audit-layer claim; sellers persist it as opaque cargo and do not recompute it. | +| `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`. | + +Cross-language implementations SHOULD validate payload canonicalization and the task, payload, amount, currency, and critical-marker acceptance rules against the published [`governance-authorization.json` vectors](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/governance-authorization.json). The vectors pin `idempotency_key` into the business hash while excluding only top-level `governance_context` and `context`. The same file publishes 27 byte-exact Ed25519 compact-JWS cases covering positive verification, critical-extension failures, binding mismatches, malformed or widened commitments, time bounds, replay identifiers, signature tampering, and explicit zero-cost authorization. Its private key component is deliberately public test material and MUST NOT be installed as a production trust anchor. + +**Unknown-extension handling**: verifiers ignore unknown non-critical claims. When a claim changes authorization semantics, the issuer also emits the same-named protected-header marker and lists it in `crit`; verifiers that do not recognize that header extension MUST reject. Verifiers MUST also reject when the critical marker and its corresponding claim do not appear together. + +**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. + +When `runtime_attestation_binding_digest` is present, governance agents MUST retain the full ordered evaluations and attestation-bound findings in the corresponding `get_plan_audit_logs` check entry. The entry-level digest, response-level digest, and signed claim MUST be identical. Auditors recompute the digest from the retained arrays; any mismatch means the signed decision and audit readback disagree. This claim MUST NOT appear in `crit`: like `plan_hash`, it is audit-layer evidence that ordinary sellers cannot independently recompute from the spend-commit request. + +**`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.3.0-beta.3/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): an RFC 9421 HTTP Signature verified through [Agent resolution](#agent-resolution). The buyer's brand.json is the exact operator record Agent resolution selected for the verified signing agent. Sellers MUST NOT refetch a different document from that record's host. When an agency operates the buyer agent, that record is the agency's brand.json, so the agency's governance agents are the ones accepted as `iss`. Whether the agency may act for the advertiser at all is the separate brand-operator check against the advertiser's `authorized_operators[]`. + +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. + +This profile follows at most one brand.json **document-indirection variant** (`authoritative_location` or `house`) and stops; sellers MUST NOT follow document-indirection chains here. This is not permission to follow an HTTP redirect from an explicit URL: an `authoritative_location` dereference uses zero HTTP redirects. A domain-derived initial `/.well-known/brand.json` fetch uses the bounded original-host/`www` HTTP policy defined above. + +#### 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. This is the relying-party record in [Agent resolution](#agent-resolution). Select the candidate collection as step 2 of the [brand-authorization cross-check](#designated-task-response-signing) does, with the domain of the governed request's `brand` in place of `brand_domain`: the top-level `agents[]`, `house.agents[]` when the house domain matches, or the matching inline brand's `agents[]` (falling back to `house.agents[]` only when that brand does not override `agents[]`). Never a sibling brand's. Locate exactly one candidate whose `type` is `governance` and whose `url` canonically equals the token's `iss`. Reject if no entry matches or more than one does. +3. Use the entry's `jwks_uri` if declared. If absent, default to `/.well-known/jwks.json` at the origin of the matched entry's `url` (scheme+host+port per RFC 6454). Multi-tenant governance agents serving multiple buyers from a shared origin MUST declare explicit per-tenant `jwks_uri` so tenant key material is not pooled across the origin. Sharding is also a size requirement, not only an isolation one: each `jwks_uri` is fetched under the `MAX_JWKS_BYTES` budget (64 KiB — see the verifier pseudocode below), which is the JWKS-specific cap and is deliberately tighter than the generic 5 MB SSRF body ceiling. A single JWKS that pools hundreds of per-tenant keys exceeds 64 KiB and is rejected, so per-tenant `jwks_uri` (each serving a small key set) — not one aggregated document — is the conformant path at scale. +4. Fetch the JWKS over HTTPS. +5. Locate the key in the JWKS whose `kid` matches the token header. On cache miss for a `kid`, refetch the JWKS once (respecting a minimum 30-second cooldown to prevent unbounded refetches) before rejecting. + +**JWKS cache TTL** MUST be bounded above by the revocation-list polling interval (see [Revocation](#revocation)). Longer cache TTLs defeat revocation: if a compromised `kid` is added to `revoked_kids` but the seller's JWKS cache still serves the revoked key for validation, only the revocation check (performed independently per step 14) catches the fraud. + +**SSRF protection**: `jwks_uri` and the revocation-list URL are counterparty-supplied. All outbound fetches to these URLs MUST follow the SSRF controls defined in [Webhook URL validation](#webhook-url-validation-ssrf): reject non-HTTPS, reject resolved IPs in reserved ranges (including cloud metadata addresses), pin the connection to the validated IP, refuse redirects, cap response size and timeouts, suppress detailed error messages to the counterparty. A JWS profile without SSRF discipline on key discovery is a metadata-exfiltration vector. + +#### Seller verification checklist + +Services apply this checklist to two distinct authorization gates. On an inbound governed request they verify the buyer-supplied `intent` token. When the advertised task mode includes `online_execution_check`, that token permits preparation and the execution check but not the side effect; the service applies the checklist again to the execution token returned by its governance call before commit. Checks run in order and short-circuit 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 protected-header extension is not recognized by the verifier. For `authorized_commitment`, `authorized_task`, and `authorized_payload_hash`, require each boolean protected-header marker, its `crit` entry, and its payload claim to appear together. Require `authorized_task` and `authorized_payload_hash` as a pair. +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` is absent or empty. On a continuation for an already-bound action, reject if it differs from the opaque `sub` retained from the prior accepted token. The seller never compares it to `plan_id`; the issuing governance agent prevents plan swap by validating and resolving its own signed context. +11. Reject if `phase` does not match the authorization gate. The buyer-supplied token on an inbound governed request MUST be `intent`. For a task advertising `online_execution_check`, the token returned to the service by its own pre-commit governance call MUST be `purchase` for a create, `modification` for an update, or `delivery` for a delivery check. A service MUST NOT substitute the inbound intent token for that required execution token. +12. Validate action binding. For an inbound intent token, reject unless `authorized_task` equals the invoked task and `authorized_payload_hash` equals the JCS/SHA-256 hash recomputed over the received request after removing exactly `governance_context` and `context`. For `modification` and `delivery`, reject if `media_buy_id` is absent or does not equal the request's media buy ID. For `purchase`, compare it when present; absence is valid because prepare may precede durable ID assignment. For `intent`, reject if it is present. When the action has a monetary commitment, reject if `authorized_commitment` is absent, its currency differs, or the service's computed commitment exceeds its amount. A service that cannot bind the task and payload or compute the price before commit MUST NOT advertise governance enforcement for that task. +13. Cross-check: the token's `iss` MUST canonically match exactly one governance-typed agent in the buyer's current brand.json (established via [Buyer identity resolution](#buyer-identity-resolution)), per [Agent resolution](#agent-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 may the service proceed through the gate represented by that token. For `signed_context`, a valid inbound intent token is the task authorization. For `online_execution_check`, it authorizes prepare/check only; commit additionally requires the valid execution token. Services do not verify `plan_hash` — that claim is bound at the governance-agent / auditor layer (see [Plan binding and audit](/dist/docs/3.3.0-beta.3/governance/campaign/specification#plan-binding-and-audit)). + +#### 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", "gov-2026-01"], + "revocation_details": { + "gov-2026-03": { "reason": "keyCompromise", "invalidity_date": "2026-04-17T09:00:00Z" }, + "gov-2026-01": { "reason": "superseded" } + } +} +``` + +`revocation_details` is optional, and keyed by `kid` for keys that appear in `revoked_kids`. Each entry MAY carry two fields: +- `reason`: a reason code from RFC 5280 §5.3.1, in the JSON spelling RFC 8555 §7.6 uses (`keyCompromise`, `superseded`, `cessationOfOperation`, and so on); +- `invalidity_date`: the date on which the private key is known or suspected to have been compromised (RFC 5280 §5.3.2). It may be earlier than the revocation. + +A revoked key is rejected whatever its detail. As in X.509, an entry is never withdrawn or weakened once published. Verifiers keep the most severe detail they have seen for a key: a `keyCompromise` reason, and the earliest `invalidity_date`. A signer that revoked a key by mistake publishes a new key. + +- `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, or `iat` / `nbf` in future beyond allowed skew | 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`, authenticated `caller`, opaque action-binding (`sub`), `phase`, `media_buy_id`, `authorized_task`, `authorized_payload_hash`, or `authorized_commitment` currency/ceiling mismatch | No | `governance_token_not_applicable` | Reject. Token valid but not for this operation. Missing or malformed required claims use `governance_token_invalid`; a well-formed token whose bindings do not match the attempted operation uses this code. | + +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", + "crit": ["authorized_commitment", "authorized_task", "authorized_payload_hash"], + "authorized_commitment": true, + "authorized_task": true, + "authorized_payload_hash": true +} +``` + +Payload: +```json +{ + "iss": "https://governance.example.com", + "sub": "gb_01HWZXABCDEFG1234567890", + "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", + "authorized_commitment": { "amount": 25000, "currency": "USD" }, + "authorized_task": "create_media_buy", + "authorized_payload_hash": "grD8ZKqJa7KJuLX_EJ4iZ5tCVGfOnJrL-o9Q_qXl7xU", + "policy_decision_hash": "9b2a...f41c", + "audit_log_pointer": "https://governance.example.com/audit/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; priorBindingId?: string; mediaBuyId?: string; phase: "intent" | "purchase" | "modification" | "delivery"; + task: string; governedPayloadHash: string; commitment?: { amount: number; currency: string }; + 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, + crit: { authorized_commitment: true, authorized_task: true, authorized_payload_hash: true }, + }).catch(() => { throw new GovTokenError("governance_token_invalid"); }); + + if (typeof payload.sub !== "string" || payload.sub.length === 0) + throw new GovTokenError("governance_token_not_applicable"); + if (ctx.priorBindingId && payload.sub !== ctx.priorBindingId) + throw new GovTokenError("governance_token_not_applicable"); + if (payload.phase !== ctx.phase) throw new GovTokenError("governance_token_not_applicable"); + if (payload.authorized_task !== ctx.task || payload.authorized_payload_hash !== ctx.governedPayloadHash) + throw new GovTokenError("governance_token_not_applicable"); + if (ctx.commitment) { + const ceiling = payload.authorized_commitment as { amount?: number; currency?: string } | undefined; + if (!ceiling || ceiling.currency !== ctx.commitment.currency || typeof ceiling.amount !== "number" || ctx.commitment.amount > ceiling.amount) + 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.3.0-beta.3/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 — proving control of a request-signing key with replay and tampering protection. A valid signature alone proves only that the key signed the request. Agent identity is established after the verified publication chain binds that key to exactly one canonical operator `brand.json` `agents[].url`. 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. + + +**Two signing profiles in 3.3.** +- **The AdCP RFC 9421 profiles** (`adcp/request-signing/v1` for requests and `adcp/webhook-signing/v1` for webhooks, keys through brand.json) are **deprecated in 3.3**. They stay fully functional through 3.x and are removed in 4.0. +- **The Web Bot Auth profile** (`web-bot-auth`) is experimental in 3.3. Identity is the signer's origin, keys come from that origin's key directory, and no capabilities call is needed. See [Web Bot Auth identities](#web-bot-auth-identities). New implementations should build it. + +This section describes the RFC 9421 profile unless a subsection says otherwise. The decision is [DR-0023](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0023-agent-identity-is-an-origin.md). + + +AdCP 3.0 defines this profile as **optional and capability-advertised** via `request_signing` on `get_adcp_capabilities`. AdCP 3.2 keeps signing optional but makes body integrity mandatory whenever signing is used: every accepted signature on a request with a body covers `content-digest`. AdCP 4.0 — the next breaking-changes accumulation window — additionally requires signing for spend-committing operations. See [Transport migration timeline](#transport-migration-timeline). + +**Roles:** +- **Agents** sign requests with a key published at their own `jwks_uri` in their operator's brand.json `agents[]` entry. The operator (the domain hosting brand.json) may be a house buying direct or an authorized third party — this profile does not distinguish. The signer is always an agent. +- **Sellers** verify the signature against the signing agent's published key, establishing agent identity. Sellers then perform the separate brand-operator authorization check (outside this profile's scope). +- **Sellers calling agent-side AdCP endpoints** (e.g., buyer-hosted mutation callbacks that are themselves AdCP protocol calls) sign their outgoing requests symmetrically; the receiving agent verifies against the seller's keys published under the seller operator's brand.json `agents[]` entry. Push-notification webhook callbacks (`push_notification_config.url` and similar asynchronous one-way notifications) are covered by the symmetric [Webhook callbacks](#webhook-callbacks) variant of this profile — the seller signs outbound with its `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` value is still accepted) and the buyer verifies. + +**Dependencies:** +- Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the [AdCP JWS profile](#adcp-jws-profile) above. Request verification never accepts another key purpose: a request-signing JWK MUST declare `"adcp_use": "request-signing"`, `"use": "sig"`, `"key_ops": ["verify"]`, and a `kid` that does not appear on any other JWKS entry with a different `adcp_use`. Verifiers enforce all four; see [Agent key publication](#agent-key-publication). The webhook path has its own explicit relaxation because the webhook `tag` provides domain separation. +- Resolves the identity-bootstrapping dependency in [Buyer identity resolution](#buyer-identity-resolution) for governance: a seller that verifies a request signature has a cryptographically established signing agent identity and MAY use the signing agent's operator domain as the brand.json resolution input for the governance verification step. + +**Conformance.** Verifier behavior is graded by the universal capability-gated storyboard at [`/compliance/latest/universal/signed-requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests), which runs for any agent advertising `request_signing.supported: true`. The storyboard exercises every step in the [verifier checklist](#verifier-checklist-requests) below and every canonicalization-edge rule in this profile, against the test vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/). To run the CLI grader against your own agent, see [Auth Graders](/dist/docs/3.3.0-beta.3/building/verification/grading). + +**No general-purpose RFC 9421 response-signing profile.** This profile signs the *request*; AdCP 3.x defines no general-purpose paired profile for signing the synchronous response *transport*. Sellers MUST NOT apply RFC 9421 §2.2.9 response signing to synchronous AdCP responses (whether MCP `tools/call` or A2A non-streaming responses including streaming `artifactUpdate` frames), and buyers MUST NOT rely on an RFC 9421 response signature on the synchronous reply. Integrity of the immediate response transport rests on TLS within the authenticated session that carried the request, modulo the standard edge-termination caveats that govern request-side body integrity at body-modifying CDNs. Durable at-rest attestation for artifacts that need to survive past the session — including specialism-scoped payloads (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts, bilateral non-repudiation receipts such as `plan_receipt`) — is the job of [signed webhooks](#webhook-callbacks) (signed with the `adcp_use: "request-signing"` key). The split is deliberate — see [Security Model: What gets signed](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/brand-protocol/tasks/verify_brand_claim#trust-model) and [Building a brand agent — Signing setup](/dist/docs/3.3.0-beta.3/brand-protocol/building-a-brand-agent#signing-setup). + +Any task not on this list MUST NOT sign its response under any signing primitive. General-purpose response-signing helpers applying RFC 9421 §2.2.9 to arbitrary tools (regardless of which `tag` or `adcp_use` string they coin) are operating outside this profile and are not 3.x-conformant; the only response-signing primitive the spec authorizes in 3.x is payload-envelope JWS on the designated-tasks list. + +The `adcp_use: "response-signing"` value is therefore reserved at the JWK layer for the payload-envelope primitive. **Keys published with `adcp_use: "response-signing"` MUST sign only payload-envelope JWS as defined in this section; using such a key to produce RFC 9421 §2.2.9 transport signatures is a profile violation regardless of the task being signed.** If a future major version scopes RFC 9421 transport response signing for any task, it MUST use a distinct `adcp_use` value (e.g., `"response-transport-signing"`) so verifiers can disambiguate the primitive from the JWK alone — the brand-protocol value cannot be retconned to cover both. List growth and additional primitives are normative decisions deferred to future spec versions. + +Designated-task success responses MUST carry a `signed_response` member matching [`response-payload-jws-envelope.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/response-payload-jws-envelope.json). The envelope payload is the canonical signed task-body object and MUST include `typ: "adcp-response-payload+jws"`, `task`, `brand_domain`, `agent_url`, `request_hash`, `iat`, `exp`, and `response`. The outer task-body fields are convenience fields for ordinary task consumers; verifiers that rely on the signature MUST reject the envelope if any unsigned task-body field disagrees with `signed_response.payload.response`. Protocol/version envelope fields are excluded from this comparison, including `status`, `context_id`, `task_id`, `message`, `timestamp`, `replayed`, `adcp_version`, and `adcp_major_version`. + +This profile uses ordinary JWS signing, not RFC 7797 unencoded payloads. The JWS Signing Input is `BASE64URL(UTF8(protected)) || "." || BASE64URL(UTF8(JCS(payload)))`, where `payload` is `signed_response.payload` and `protected` decodes to `{ "alg": "EdDSA" | "ES256", "kid": "...", "typ": "adcp-response-payload+jws" }`. The protected header MUST NOT contain `b64`. Response verifiers MUST enforce the shared JWS discovery and hardening rules from the [AdCP JWS profile](#adcp-jws-profile): allowed algorithms, `use: "sig"`, `key_ops` containing `"verify"`, exact `adcp_use: "response-signing"`, missing-`kid` refetch, revocation checks, SSRF-safe JWKS fetches, and duplicate-key rejection before canonicalization. + +`request_hash` is `sha256:` plus unpadded base64url SHA-256 of the JCS canonical request-binding object `{ task, brand_domain, agent_url, caller_identity, request }`. `caller_identity` MUST be a typed canonical string derived from the authenticated transport or credential mapping, such as `signed-agent-url:`, `api-client-id:`, or `mtls-san:`. If no authenticated caller identity exists, `caller_identity` is `null` and the verifier MUST treat the response as weaker evidence that is not bound to a caller. + +`brand_domain` is a tenant-binding field, not an echo. A multi-brand agent MUST populate it from its server-side tenant resolution and the brand.json entry whose policy store produced the answer; it MUST NOT copy it from the request body. `agent_url` is the canonical URL of the responding `agents[]` entry whose `response-signing` JWK verifies the envelope. Online verifiers MUST reject envelopes at or after `exp` after applying only small clock-skew tolerance; audit verifiers MAY verify after `exp`, but only as historical evidence that the brand-agent signed that payload during the stated `iat`/`exp` window. + +**Brand-authorization cross-check (3.2 advisory).** This check is [Agent resolution](#agent-resolution) with the `brand_domain` brand.json as the relying-party record and `type: "brand"` required. A valid signature proves only that *some* agent holding a `response-signing` key signed the payload — not that the key is authorized to answer for `brand_domain`. `brand_domain` and `agent_url` are fields *inside* the payload the same key signs, so resolving the envelope's `kid` from an attacker-selected agent origin confirms authorship, not brand authority. Before treating a cryptographically valid `verify_brand_claim` / `verify_brand_claims` envelope as evidence about the asserted `brand_domain`, an online 3.2 verifier SHOULD: + +1. Resolve `https://{brand_domain}/.well-known/brand.json` following the SSRF-safe [brand.json resolution](#buyer-identity-resolution) rules: the initial domain-derived HTTP fetch may follow only the original hostname ↔ exact `www` counterpart on HTTPS at the original port for at most 3 hops, while any explicit `authoritative_location` URL uses zero HTTP redirects. This profile follows at most one JSON document-indirection variant, with no document-indirection chain. Apply the trust-root fetch budgets: strict JSON parsing with duplicate keys rejected, a 2 MiB `MAX_BRAND_JSON_BYTES` body cap enforced while the body streams, 5 s connect and 10 s total deadlines, successful-cache lifetime no longer than the JWKS revocation polling interval, and negative caching of at most 60 s. +2. Resolve the agent collection for the asserted domain, then locate exactly one entry whose `type` is `brand` and whose `url` matches the envelope's `agent_url` after applying the shared [URL canonicalization](/dist/docs/3.3.0-beta.3/reference/url-canonicalization) algorithm. For a Brand Agent or Brand Canonical Document, use the top-level `agents[]`. For a House Portfolio whose `house.domain` equals `brand_domain`, use `house.agents[]`. Otherwise, locate exactly one inline `brands[]` entry whose `url` hostname equals `brand_domain`; use that brand's `agents[]` when present, or `house.agents[]` only when the brand does not override `agents[]`. Never aggregate sibling brands' agents, and do not search `brand_refs[]`, because those entries require resolving the child's canonical document. No domain or agent match is `agent_not_authorized`; multiple inline-domain or canonical agent matches are `agent_authorization_ambiguous`. +3. Resolve the JWKS source **only** from that matched entry's `jwks_uri`, defaulting when absent to `/.well-known/jwks.json` at the origin of the matched entry's `url`. Never accept a JWKS URI supplied by the envelope or discovered independently from the envelope's `agent_url` host. Deduplicate fetches by canonical resolved JWKS URI. +4. Require exactly one JWK whose `kid` matches the protected header, enforce `use: "sig"`, `key_ops` containing `"verify"`, and exact `adcp_use: "response-signing"`, then bind it to the key that verified the envelope. Either re-verify the JWS with this authorized JWK or compare RFC 7638 thumbprints of the authorized and already-verified public keys. Equal `kid` strings are insufficient; zero matches, duplicate matches, or different key material are `untrusted`. + +The cross-check produces a verifier-local [`BrandResponseAuthorizationResult`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/brand-response-authorization-result.json); it is never a field asserted by the responding agent. A successful lookup and key-material binding returns `trust: "trusted"`. Any fetch failure, missing or ambiguous agent entry, missing or duplicate `kid`, key-material mismatch, or key-purpose failure returns `trust: "untrusted"` with the corresponding reason. If a verifier skips the cross-check, it MUST treat the envelope as `untrusted` for relationship decisions. “Advisory” means a failed or skipped cross-check does not transport-reject an otherwise cryptographically valid response; it does not make authorization verification optional when extending or revoking relationship trust. A 3.2 verifier MUST NOT let an untrusted result extend or revoke relationship trust on its own, but MAY retain the cryptographically valid envelope as unauthenticated historical evidence or combine it with independent mutual-assertion evidence. It MUST NOT hard-reject solely because this additive advisory check failed. Mandatory cryptographic authorization binding and incompatible hard-fail behavior are deferred to 4.0. + +For `verify_brand_claims`, implementations SHOULD deduplicate brand.json fetches by `brand_domain` and JWKS fetches by canonical resolved `jwks_uri` within the batch verification operation to avoid request amplification against brand origins. The advisory applies symmetrically to assertions (`owned`, `licensed_*`, and similar) and rejections (`disputed`, `not_ours`). Conformance vectors and the paired executable reference evaluator live at [`brand-response-signing/brand-authorization-cross-check`](https://adcontextprotocol.org/compliance/latest/test-vectors/brand-response-signing/brand-authorization-cross-check.json). + +Response-signing keys are scoped by brand tenant as well as by purpose. A shared multi-brand fleet MUST publish distinct response-signing key material and distinct `kid` values for each `brand_domain` it serves, even when the same software and `agent_url` serve multiple brands. Cross-brand reuse of a response-signing JWK is a profile violation because it defeats tenant-bound replay analysis; this rule is stricter than ordinary cross-purpose separation and applies only to `adcp_use: "response-signing"` keys. + + + +#### Verifier checklist (designated-task response signing) + +Verifiers consuming a `signed_response` on `verify_brand_claim` or `verify_brand_claims` MUST apply these checks in order, short-circuiting on the first failure. Steps 1–6 establish cryptographic validity; steps 7–10 enforce the envelope's binding claims. The [brand-authorization cross-check](#designated-task-response-signing) (advisory in 3.2) is a separate, subsequent check — it does not replace the binding checks below. + +1. Parse the `signed_response` member. Reject if `protected`, `payload`, or `signature` is absent or structurally malformed. +2. Decode the base64url-encoded JWS protected header. Reject if `alg` is not in the allowlist (`EdDSA`, `ES256`), `kid` is absent, or `typ` is not exactly `adcp-response-payload+jws`. +3. Resolve `kid` to a JWK at the responding agent's `jwks_uri` (defaulting to `/.well-known/jwks.json` at the agent URL's origin when `jwks_uri` is absent). This step establishes authorship only. It is not [Agent resolution](#agent-resolution); whether the key is authorized for `brand_domain` is the separate [brand-authorization cross-check](#designated-task-response-signing), which applies Agent resolution to the brand's record. Enforce `use: "sig"`, `key_ops` containing `"verify"`, and exact `adcp_use: "response-signing"`. Reject on any mismatch, including absent `adcp_use`. +4. Canonicalize `signed_response.payload` with RFC 8785/JCS, base64url-encode the result, construct the JWS Signing Input as `BASE64URL(UTF8(protected)) || "." || BASE64URL(UTF8(JCS(payload)))`, and verify the signature against the resolved JWK. Reject if verification fails. +5. Reject if `signed_response.payload.typ` is not exactly `adcp-response-payload+jws`. This prevents cross-profile replay — a governance JWS (`adcp-gov+jws`) or any other typed token MUST NOT validate under this profile. +6. Reject if `signed_response.payload.task` does not match the invoked task (`verify_brand_claim` or `verify_brand_claims`). This prevents cross-task replay — a valid `verify_brand_claim` envelope MUST NOT validate on a `verify_brand_claims` response and vice versa. +7. **Expiry.** Reject if `signed_response.payload.exp ≤ now − skew`, where `skew` is an implementation-defined clock-skew tolerance (SHOULD be ≤ 60 s). Online verifiers MUST reject expired envelopes with `SIGNED_RESPONSE_ENVELOPE_EXPIRED`. Audit verifiers MAY verify after `exp` as historical evidence, but MUST NOT treat the result as current truth. Also reject if `iat > now + skew` (issued in the future). +8. **Request-hash binding.** Recompute the SHA-256 hash of the JCS-canonical request-binding object `{ task, brand_domain, agent_url, caller_identity, request }` using the same derivation rules documented in [the payload field definitions above](#designated-task-response-signing). Reject with `SIGNED_RESPONSE_REQUEST_HASH_MISMATCH` if the recomputed hash does not byte-match `signed_response.payload.request_hash`. This check binds the signed response to the exact request that produced it — without it, a signed response from one request can be replayed as the answer to a different request. +9. **Tenant binding.** Reject with `SIGNED_RESPONSE_TENANT_MISMATCH` if `signed_response.payload.brand_domain` does not match the verifier's resolved or expected brand tenant for this call. The brand agent derives `brand_domain` from server-side tenant resolution, not from caller-supplied request fields; a mismatch indicates cross-tenant replay or a multi-brand agent routing error. +10. **Payload consistency.** Reject if any unsigned task-body field on the outer response disagrees with the corresponding field in `signed_response.payload.response`. Protocol/version envelope fields (`status`, `context_id`, `task_id`, `message`, `timestamp`, `replayed`, `adcp_version`, `adcp_major_version`) and `signed_response` itself are excluded from this comparison. + +Only after all 10 checks pass does the verifier treat the signed response as cryptographically valid and bound. The subsequent [brand-authorization cross-check](#designated-task-response-signing) (3.2 advisory) determines whether the signing key is authorized to answer for `brand_domain` — that check is independent of the binding checks above. + +#### Transport scope + +| Class | 3.0–3.1 | 3.2 | 4.0 | +|---|---|---|---| +| Spend-committing (`create_media_buy`, `update_media_buy`, `acquire_*`, `activate_signal`) | Signing optional; legacy body-binding modes available | Signing optional; every signed body is digest-bound | Signing required and every signed body is digest-bound | +| Reversible state changes (`sync_creatives`, `update_creative_status`) | Signing optional; legacy body-binding modes available | Signing optional; every signed body is digest-bound | Signing recommended; every signed body is digest-bound | +| Read / discovery (`get_products`, `get_media_buy_delivery`, `list_*`) | Not in scope | Not in scope | Not in scope | +| TMP `provider_endpoint_url` requests | Out of scope (TMP has its own envelope) | Out of scope | 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 + +For implementers adopting request signing in 3.2 ahead of the 4.0 enforcement 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 (for example, A2A 0.3 `tasks/cancel` or A2A 1.0 `CancelTask`) the seller's verifier covers. A 3.2 signing peer MUST advertise `covers_content_digest: "required"`; `"either"` and `"forbidden"` identify a legacy 3.0/3.1 signing posture only. +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` and `covers_content_digest: "required"` in `get_adcp_capabilities`. Leave `required_for: []` during the pilot; add operations incrementally per counterparty. Signing remains optional in 3.2, but a signature that is presented on a request with a body is never body-optional. +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 for a pilot counterparty before populating `required_for`. Record well-formed verification and body-binding failures, establish no signer identity from them, and continue only under independent authentication. Partial or malformed signature-header pairs still hard-reject. Surface verification failures in monitoring 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 3.2 verifier:** implement the complete checklist, including digest recomputation, before advertising `request_signing.supported: true`. The older 3.0/3.1 shadow-mode allowance that deferred digest enforcement does not satisfy the 3.2 body-integrity contract. Do not skip ahead of step 1 — malformed signatures always reject, never fall back. + +#### Production key storage + +Where the signer's private key lives is implementation-defined — the spec is concerned only with the bytes on the wire — but operators SHOULD avoid holding private signing keys in process memory in production. A process compromise leaks the signing key, and the only remedy is rotation across every counterparty that's cached the public key (within their cache TTL). + +The recommended pattern: an SDK exposes a pluggable signer interface (e.g., `sign(payload: Uint8Array): Promise`), and the operator's adapter delegates the operation to a managed key store — AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault Transit, or an HSM. The key never leaves the managed store; the SDK builds the canonical signature base, the store signs it, the SDK assembles `Signature` and `Signature-Input` headers from the returned bytes. Wire format is identical to in-process signing. + +Two implementation notes for adapter authors: + +- ECDSA-P256 signatures returned by most KMS APIs are DER-encoded; this profile and RFC 9421 §3.3.1 require IEEE P1363 (`r‖s`, 64 bytes for P-256). Convert at the adapter boundary. +- Treat the KMS key as single-purpose. The `tag` parameter in this profile protects verifiers, not signers — an operator who reuses the same KMS key for AdCP request-signing and any other signing protocol creates a cross-protocol oracle. Bind the KMS access policy (GCP `roles/cloudkms.signer` scoped to the specific cryptoKey, AWS `kms:Sign` conditioned on the key ARN) so only the AdCP signing path can invoke the key. + +Reference implementations: `@adcp/sdk` (TypeScript) ships a `SigningProvider` interface with sync/async parity, an in-memory provider for tests, and a GCP KMS reference adapter at [`examples/gcp-kms-signing-provider.ts`](https://github.com/adcontextprotocol/adcp-client/blob/main/examples/gcp-kms-signing-provider.ts). See the [SDK signing guide](https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/SIGNING-GUIDE.md#step-35-production-key-storage--kms--hsm--vault) for the full walkthrough. + +**Tripwire pattern — assert public key at init.** Managed key stores can silently rotate (IAM policy swap, version disable, hostile substitution). If rotation happens without updating the published JWKS, verifiers fetching the unchanged `kid` will reject every signature with no clear error signal — the operator sees counterparty failures, not a KMS mismatch. The defense: commit the expected public key (SPKI bytes, base64-encoded) alongside the code, and at signer init byte-compare it against the key the store returns (`getPublicKey()` or equivalent). A mismatch fails loudly at startup rather than silently on every signed call. Rotation then becomes a deliberate two-step: update the pinned constant, set the new key version path, deploy. + +**Lifecycle: lazy init, not eager.** Calling `getPublicKey` (or any KMS warm-up call) before the process binds its listener looks clean in review but has a dangerous failure mode: if KMS auth is misconfigured, gRPC / TLS retries inside the KMS client can block indefinitely, the process never opens its port, and the infrastructure health-check times out — surfacing a "service unreachable" alarm rather than the underlying KMS error. The correct lifecycle is lazy init on first sign: call the store the first time a request needs signing, cache the result only on success (never cache errors), and deduplicate concurrent first-call requests with an in-flight promise. Fail-fast misconfig detection belongs in a CI/CD pre-deploy probe that exercises the KMS path with the deployment target's credentials before cutover — not at process startup. + +**One JWK per `adcp_use` — publication shape.** The single-purpose rule applies to key material **and** to JWKS publication. Note that webhooks do not need their own purpose: they are signed with a `"request-signing"` key (see [Webhook callbacks](#webhook-callbacks), step 8), so an operator that signs both requests and webhooks with the same key publishes a single `"request-signing"` entry. An operator that wants separate key material for webhooks (blast-radius isolation) publishes a **second `"request-signing"` key with a distinct `kid`** — isolation comes from the `kid`, not a distinct `adcp_use`. The `adcp_use` value is always a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject: + +```json +{ + "keys": [ + { + "kty": "OKP", "crv": "Ed25519", + "x": "SRYr8eSvjkZF6dAUquI1sKuU4YGZkoGH-2jwkz4dRJg", + "kid": "acme-signing-2026-04", + "alg": "EdDSA", "use": "sig", + "adcp_use": "request-signing", + "key_ops": ["verify"] + }, + { + "kty": "OKP", "crv": "Ed25519", + "x": "lHJI-IvBwCE36heDNOyBmCk5UMKRIs4b4BAWJRgao-M", + "kid": "acme-webhook-2026-04", + "alg": "EdDSA", "use": "sig", + "adcp_use": "request-signing", + "key_ops": ["verify"] + } + ] +} +``` + +This second entry is the optional webhook-isolation key from the [Key publication](#webhook-callbacks) section: same `adcp_use: "request-signing"`, distinct `kid`, used to sign webhooks so a webhook-key compromise doesn't extend to request signing. Distinct `kid` values also mean counterparties can cache and rotate the two keys independently. + +#### AdCP RFC 9421 profile + +This profile constrains RFC 9421 to a single canonical shape so cross-implementation interop is tractable. + +**Covered components (REQUIRED on every signed request):** + +| Component | Notes | +|---|---| +| `@method` | Uppercase. | +| `@target-uri` | Canonicalized per the algorithm below. Signer MUST apply canonicalization before computing the signature base; verifier MUST apply the same canonicalization to the received request before verifying. | +| `@authority` | Lowercased `host[:port]`, default ports (`443` for https, `80` for http) stripped. | +| `content-type` | Required on requests with bodies. | +| `content-digest` | Governed by the verifier's `request_signing.covers_content_digest` capability — see [Content-digest and proxy compatibility](#content-digest-and-proxy-compatibility). | + +**`@target-uri` canonicalization** follows the [AdCP URL canonicalization rules](/dist/docs/3.3.0-beta.3/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 / one trailing DNS root dot removed / IPv6 bracketing preserved; userinfo stripped; default port stripped). IPv6 hosts retain their brackets in `@authority` (`[::1]:8443`). A signer and verifier MUST remove exactly one trailing DNS root-label dot before deriving both `@target-uri` and `@authority`; `https://example.com./p` therefore yields `https://example.com/p` and `example.com`. The same rule applies to equivalent IDN U-label and A-label inputs, so `bücher.example.` and `xn--bcher-kva.example.` both yield authority `xn--bcher-kva.example`. An authority with multiple trailing dots is malformed and MUST be rejected with `request_target_uri_malformed`. Verifiers MUST apply these rules to the HTTP authority source before comparison. 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. + +**MCP transport sessions are non-authoritative.** `Mcp-Session-Id` is not a +covered component in this profile and MUST NOT carry AdCP-relevant authority. +A verifier MUST NOT attribute identity, authorization, account selection, +resource access, or task ownership on the basis of `Mcp-Session-Id` or other +unsigned session-continuity or routing metadata. This prohibition does not +include recognized per-request credential channels such as `Authorization`. +For an operation that requires authentication or authorization, the decision +MUST derive from those per-request credentials and, when request signing +applies, the covered components and digest-bound body. Public and bootstrap +operations retain their documented authentication policy. A stateful MCP +implementation MAY use a session for transport continuity or task waiting. +It MAY also use session-local caches only after per-request authentication and +only when every entry is additionally keyed by the authenticated principal and +account. Rebinding an otherwise valid request to another session MUST NOT +change the principal, authorization decision, resource namespace, returned +cached data, or ownership decision applied to that request. + +**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. + +**Request-profile binary value encoding (`Signature`, `Content-Digest`).** RFC 9421 and RFC 9530 encode these values as RFC 8941 Structured Field `sf-binary` tokens (`::`). AdCP 3.2 request signing (`tag="adcp/request-signing/v1"`) aligns with those RFCs: signers MUST use the standard RFC 4648 §4 Base64 alphabet with `+` and `/` and the padding required by RFC 8941; request verifiers MUST parse the field as `sf-binary` before using its decoded bytes. Base64URL characters (`-` and `_`) are not valid inside 3.2 request `sf-binary` and MUST be rejected as `request_signature_header_malformed`. This differs intentionally from the `nonce` signature parameter, whose quoted string value uses unpadded Base64URL. + +AdCP 3.0/3.1 request signing used a legacy profile override that encoded these binary values as unpadded Base64URL. A request signer selects the wire encoding from the trusted negotiated signing profile: use legacy Base64URL only on an explicitly negotiated 3.0/3.1 request-signing endpoint, and use RFC 8941 Base64 for 3.2 and later. A 3.2 request verifier MUST NOT reinterpret a Base64URL token as `sf-binary` or retry it through a legacy decoder. The versioned conformance fixtures preserve both profiles so SDKs can test an intentional migration rather than accepting both alphabets ambiguously. The separately routed `adcp/webhook-signing/v1` profile retains its legacy Base64URL encoding throughout 3.x; webhook parser selection is endpoint/profile configuration and MUST NOT enable a fallback in the 3.2 request parser. + +The `expected_signature_base` field in the conformance vectors is independent of binary-value encoding: it contains the canonical signature-base bytes, while the emitted `Signature` token contains the resulting signature bytes encoded as `sf-binary`. + +**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. (The AdCP A2A profile sets the invocation `skill` to the AdCP operation name exactly, so the two coincide there; see [Operation resolution](#operation-resolution-over-a2a).) 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 uses slash-path JSON-RPC method names such as `tasks/cancel` and `tasks/pushNotificationConfig/set`; A2A 1.0 uses PascalCase names such as `CancelTask` and `CreateTaskPushNotificationConfig`. MCP transports may also register `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` | Exact JSON-RPC method strings (for example, `"tasks/cancel"`, `"tasks/pushNotificationConfig/set"`, or `"CancelTask"`) | 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`: record well-formed verification failures, establish no signer identity, and continue only under independent authentication. Malformed signature-header pairs still hard-reject. | +| `request_signing.protocol_methods_required_for` | Same | Reject unsigned matches with `request_signature_required`. | + +The matched value is the decoded JSON string in the JSON-RPC envelope's `method` field, **not** the MCP `tools/call` `params.name`. Matching MUST use exact, case-sensitive string equality after JSON decoding: `tasks/cancel`, `CancelTask`, and `cancelTask` are distinct strings, while JSON escape spellings that decode to the same string are not distinct method names. Implementations MUST NOT translate, case-fold, apply Unicode normalization, or otherwise normalize names between A2A versions. A dual-stack agent therefore lists every wire name it covers; declaring `tasks/cancel` does not also declare `CancelTask`. + +The two A2A versions name the same methods differently, so check the pairs you care about: + +| A2A 0.3 | A2A 1.0 | +|---|---| +| `message/send` | `SendMessage` | +| `message/stream` | `SendStreamingMessage` | +| `tasks/get` | `GetTask` | +| `tasks/cancel` | `CancelTask` | +| `tasks/resubscribe` | `SubscribeToTask` | +| `tasks/pushNotificationConfig/set` | `CreateTaskPushNotificationConfig` | + +The A2A 0.3 and 1.0 names used by the conformance vectors are in [`protocol-method-names.json`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/protocol-method-names.json). Say a seller serves A2A 0.3 and 1.0 clients and requires signatures on task cancellation and push-notification registration. It declares both spellings of each method: + +```json +{ + "request_signing": { + "supported": true, + "covers_content_digest": "required", + "supported_for": ["create_media_buy"], + "required_for": ["create_media_buy"], + "protocol_methods_supported_for": ["tasks/cancel", "CancelTask", "tasks/pushNotificationConfig/set", "CreateTaskPushNotificationConfig"], + "protocol_methods_required_for": ["tasks/cancel", "CancelTask", "tasks/pushNotificationConfig/set", "CreateTaskPushNotificationConfig"] + } +} +``` + +An A2A 1.0 client cancels with `{"jsonrpc": "2.0", "method": "CancelTask", ...}`. That matches `CancelTask`. If the seller had listed only `tasks/cancel`, the request wouldn't match, and an unsigned 1.0 cancel wouldn't get `request_signature_required`. `message/send` works the same way: listing it doesn't cover A2A 1.0 `SendMessage` requests, and listing `SendMessage` covers every A2A 1.0 message no matter which AdCP operation it carries. So it's rarely what you want in `protocol_methods_required_for`: it would also cover `get_adcp_capabilities` and `get_products`, and an unsigned capability fetch is how a new buyer bootstraps. To require signatures for one AdCP operation over A2A, list the operation in `required_for` and resolve it as [Operation resolution over A2A](#operation-resolution-over-a2a) describes. + +`lower_snake_case` AdCP operation names such as `create_media_buy` MUST NOT appear in any `protocol_methods_*` array. The reserved MCP envelope method `tools/call` also MUST NOT appear there because its AdCP operation identity is the decoded `params.name`, which is matched against `supported_for` / `warn_for` / `required_for`. JSON-RPC protocol method names — slash-path A2A 0.3 names or PascalCase A2A 1.0 names — MUST NOT appear in those AdCP-operation arrays. 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 JSON-RPC `method` string itself.** A `required_for` membership is satisfied only by the AdCP operation the verifier resolves from the request, which is `params.name` for `tools/call` and the invocation `skill` for A2A messages ([Operation resolution over A2A](#operation-resolution-over-a2a)). The two buckets are matched against disjoint decoded envelope fields: `protocol_methods_*` against the JSON-RPC `method` only, the operation lists against the resolved operation only. + +The signature-base construction is identical for both namespaces and both A2A versions: 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` or `CancelTask` POST sign exactly as they would for any other mutating call; the protocol-method fields only change the seller's declaration of which JSON-RPC methods are in scope for verification. + +**Body substitution on shared transport.** When a single `@target-uri` accepts `tools/call` envelopes and JSON-RPC protocol methods (the canonical MCP layout — both POST to `/mcp`), `@target-uri` and `@method` alone do not bind the body. Without `content-digest` coverage, an on-path attacker can substitute another tool, switch `tools/call` to `tasks/cancel`, or change parameters within the same method during the signature window. Distinct URIs do not solve same-method parameter substitution, so they are not an integrity alternative. In 3.2 every accepted signature on a request with a body MUST cover `content-digest`, regardless of which operation or protocol-method list caused the verifier to process it. + +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` or `CancelTask` coverage. Buyer SDKs that sign protocol methods opportunistically 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` with the exact wire name used by the request. + +#### Operation resolution over A2A + +`required_for`, `supported_for`, and `warn_for` are matched against the **resolved AdCP operation**, which the verifier computes once from the decoded request. This section is a security erratum. It resolves a contradiction between this document, which said a `required_for` match could not be satisfied by any JSON-RPC `method` other than `tools/call`, and the [A2A profile](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension#security-and-validation), which requires the signing rules used for the equivalent task over MCP. The fail-closed reading wins, and the earlier `tools/call`-only sentence is superseded. Clauses marked *hardening* are additions, not restatements, and are SHOULD in 3.2.x and MUST from 3.3. + +| Request | Resolved operation | +|---|---| +| MCP JSON-RPC `tools/call` | The decoded `params.name`. | +| A2A JSON-RPC `SendMessage` or `SendStreamingMessage` (1.0); `message/send` or `message/stream` (0.3) | The decoded `skill` of the Message's sole DataPart (`params.message.parts[].data.skill`; A2A 0.3 Parts also carry `kind: "data"`). | +| A2A HTTP+JSON `POST …/message:send` or `…/message:stream`; A2A gRPC `SendMessage` or `SendStreamingMessage` | The same `skill`, read from the decoded request's `message`. | +| Any other JSON-RPC method (`tasks/cancel`, `CancelTask`, `GetTask`, …) | None. These match only `protocol_methods_*`. | + +The A2A profile defines the invocation shape for A2A 1.0 over JSON-RPC; for A2A 0.3 and for the other bindings an agent serves, this section extends the same resolution by analogy. An agent that cannot apply it on a binding MUST NOT dispatch AdCP operations over that binding. Resolution reads only `skill`: the `input` member, or the legacy 3.0/3.1 `parameters` member, is the dispatcher's concern. + +A verifier on an interface that dispatches AdCP operations MUST apply these rules to every POST to that interface. They key on the request's method or path, never on the `A2A-Extensions` header, so a request that omits or misstates extension activation resolves the same way. + +1. **Fail closed.** A request that does not resolve to exactly one operation MUST be rejected with `request_body_malformed`, whether or not it is signed, before the verifier consults any capability list and before the request reaches a handler. An A2A Message MUST contain exactly one DataPart, and its `data` MUST be a JSON object whose `skill` is a non-empty string; a text-only message on an AdCP interface is rejected. An unresolvable request is never "an operation that is not in `required_for`". The A2A profile already requires this shape; the rule makes the rejection a precondition of the signature gate. An interface that serves generic conversational A2A under a [separate Agent Card](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-profile-extension#relationship-to-a2a-async-and-conversation) and dispatches no AdCP operation is outside this rule. +2. **Exact match, no fallback.** The `skill` string, and the method name that selects the A2A message-carrying path, are compared by exact, case-sensitive equality after JSON decoding, with no trimming, case-folding, normalization, or alias. A `skill` that is not an AdCP operation name is rejected by the agent's ordinary unsupported-operation handling and MUST NOT reach a default handler, a text- or model-based router, or an Agent Card `skill.name`, `tags`, or alias lookup. A dispatcher MUST reject any method or path the gate would not have recognized as message-carrying, so a variant spelling such as `sendmessage` cannot reach a handler the gate treated as "no operation". +3. **One source, three uses.** The resolved operation is the only input to (a) the operation-list lookup, (b) handler dispatch, and (c) request-schema selection. Nothing else may influence it: not a TextPart, Part, `message.metadata`, or `params.metadata` value, not the URL path, a header such as `A2A-Extensions`, a `tenant` value, an Agent Card `skill.name`, or the shape of `input`. When the signature gate and the dispatcher are separate components, for example HTTP middleware ahead of an A2A executor, the gate passes its resolved operation to the dispatcher through trusted request context, or the dispatcher re-resolves with this same algorithm over the same bytes. In both cases a dispatcher whose operation differs from the gate's MUST reject before handler entry. Resuming a task by `message.taskId` MUST NOT run a handler other than the resolved operation's. These rejections come after the gate, so they are task-level `INVALID_REQUEST` errors, not transport `request_*` codes. +4. **Pre-check ordering.** Resolution runs first in the pre-check of the [verifier checklist](#verifier-checklist-requests), before the signature-header pair checks, the `required_for` test, and step 1. A request that is both unresolvable and carries a malformed `Signature` / `Signature-Input` pair is rejected with `request_body_malformed`. The rejection is a transport-taxonomy `401` carrying the error code, like every `request_*` code, not an A2A task error. +5. **Both namespaces apply.** A request can match both buckets, for example `SendMessage` in `protocol_methods_required_for` and `create_media_buy` in `required_for`. Either match requires the signature. + +**Hardening.** These are additions to the contradiction fix. Verifiers SHOULD apply them in 3.2.x and MUST from 3.3; vectors tagged `hardening` grade them. + +- **Strict parse before classification, on unsigned bodies too.** The body is exactly one JSON-RPC request object (or, for HTTP+JSON, one request object) with a string `method`. From 3.3 a verifier MUST reject a JSON array body, signed or unsigned, with `request_body_malformed`; in 3.2.x it SHOULD, and a verifier that instead resolves every element MUST apply the 3.2 interim rule (resolve every element and require a signature if any matches). The check runs in this pre-check on the first non-whitespace byte, before any signature-header check, so signed and unsigned arrays are rejected at the same point: a signed AdCP request carries exactly one JSON-RPC message, because `required_for` and `protocol_methods_*` membership, the signer's authorization, and `(keyid, nonce)` replay scope are all undefined for a body of several messages. This replaces the interim rule that let a verifier resolve every element instead. Duplicate object keys in the envelope, `params`, `message`, each Part, and `data` are rejected after JSON string decoding, so `"skill"` and `"sk\u0069ll"` collide; a verifier MAY apply the whole-body check of step 14 instead. Member names are matched case-sensitively, and an object carrying a case variant of a recognized member (`Skill`, `Parts`) is rejected, because some decoders match names case-insensitively. +- **Part shape.** A Part carries exactly one content member (`text`, `data`, `raw`, or `url` in A2A 1.0; `text`, `data`, or `file` in 0.3), and a `kind`, when present, agrees with it. The Message carries no FilePart (`raw`, `url`, `file`). +- **Task continuation binding.** A request with `message.taskId` is rejected before handler entry unless its resolved operation equals the operation the task was created for and the caller is the principal that created it. + +What this does not change: `required_for` still yields to a configured fallback authenticator under [Composition with fallback authenticators](#composition-with-fallback-authenticators), and 3.0/3.1 endpoints on the legacy `"either"` or `"forbidden"` digest posture do not bind the body. On the 3.2 profile a signed A2A request covers `content-digest`, so `skill` is bound by the signature and a captured body cannot be replayed as a different operation. `warn_for` resolves the same way, so a seller can watch unsigned A2A traffic for an operation in `warn_for` before it moves the operation to `required_for`; a seller that skips that step will start rejecting buyers that were previously getting through. Until SDK verifiers adopt the resolution rule, a verifier that predates it does not enforce `required_for` over A2A, and listing the A2A message methods in `protocol_methods_required_for` is the stopgap, at the cost of also requiring a credential on the unsigned capability fetch a new buyer uses to bootstrap. + +**Marker.** A verifier lists `a2a_invocation_skill` in `request_signing.operation_sources` to declare that it implements this section, hardening clauses included, and passes the [A2A conformance vectors](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/a2a/). The claim covers every A2A version and binding the endpoint serves, so a verifier lists it only if any binding it serves resolves the operation under this section or is disabled. A present `operation_sources` array MUST list every source the verifier supports. A verifier without the field has not declared support, and a counterparty MUST NOT infer from `required_for` that A2A requests are signature-enforced. Like every capability field, the marker is self-attested; the vectors are the real test. + +Buyers sign any operation in `required_for`, and SDKs SHOULD choose what to sign from the resolved `skill`, not the method. A signature on an operation the seller does not require costs nothing. + +#### Agent key publication + +Request-signing keys live at the signing agent's own `jwks_uri` in its operator's brand.json `agents[]` entry, and outbound webhooks are signed with `"request-signing"` keys (optionally with distinct webhook-only key material under a separate `kid`). Every agent that signs — of any `type` — uses the same publication pattern, and verifiers find it through [Agent resolution](#agent-resolution). Publisher `adagents.json` may additionally pin allowed seller keys through `authorized_agents[].signing_keys[]`; a pin narrows the keys accepted for that publisher's inventory and never adds one. + +**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.3.0-beta.3/governance/property/adagents#signing_keys)), the pin narrows the keys a verifier accepts for signatures the agent makes in its sell-side role about that publisher's inventory. Accepted keys are the intersection: a key MUST appear in the matched `agents[]` entry's JWKS and in the pin, compared by RFC 7638 thumbprint. A pinned entry without complete public-key parameters matches nothing. Verifiers MUST reject any such signature whose key does not match a pinned entry by thumbprint, and MUST also reject a pinned key that the agent's JWKS no longer publishes. A pin never adds a key, never replaces the JWKS, and does not apply to the agent's other traffic, such as requests it sends as a buyer or signatures about another publisher's inventory. 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 limits acceptance — while rotation and revocation in the agent's JWKS still take effect. Publishers are required to pin for any agent whose delegated scopes include mutating operations, because that agent's webhooks about those operations (delivery, status) are what the pin protects; see the adagents.json rule for rotation and cache semantics. Operators MUST publish every pinned key in the agent's JWKS, since a pinned key the JWKS does not publish is rejected. This is step 3 of [Agent resolution](#agent-resolution). + +Each request-signing JWK entry MUST declare: + +| Member | Value | Notes | +|---|---|---| +| `use` | `"sig"` | Standard JWK signing use. | +| `key_ops` | `["verify"]` | Verifier-visible JWKS declares verify-only. The signing operator holds the corresponding private key locally with `["sign"]` per JWK spec. | +| `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile) and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. The same `"request-signing"` key (or a second one under a distinct `kid`) also signs outbound webhooks — see [Webhook callbacks](#webhook-callbacks). (`"webhook-signing"` is a deprecated purpose pending removal — see [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); still accepted on the webhook path for backward compatibility.) | +| `kid` | distinct | Unique within the JWKS. MUST NOT collide with any other entry's `kid` regardless of `adcp_use`. | +| `alg` | `"EdDSA"` or `"ES256"` | Must match the signature's `alg` parameter (JWK `alg` uses JWS names; `alg` in `Signature-Input` uses RFC 9421 names). | + +Cross-purpose key reuse is forbidden and **locally enforceable** via `adcp_use`, except for the explicit webhook relaxation above: a `"request-signing"` key may sign either requests or webhooks because the RFC 9421 `tag` separates those profiles. A single JWK entry can only declare one `adcp_use` value, so a publisher cannot accidentally (or deliberately) present a governance-signing key as a valid request-signing key. Verifiers check `adcp_use` on the JWK they fetched, not across other JWKS endpoints — no cross-endpoint lookup is required or permitted. + +**Origin separation (MUST for governance, SHOULD for others).** `adcp_use` is an in-band discriminator — it prevents cross-purpose verification, but it does not defend the publishing origin. An origin compromise on a shared JWKS endpoint simultaneously compromises every signing purpose it publishes. Because a governance-signing key is the highest blast-radius key in the system (its compromise is a multi-tenant breach), governance signing keys MUST be served from a separate origin than transport/webhook RFC 9421 keys. The canonical pattern is: + +- `governance-keys.{org}.example/.well-known/jwks.json` — governance-signing JWKs only +- `keys.{org}.example/.well-known/jwks.json` — request-signing keys (including webhook-only `kid`s), deprecated webhook-signing keys during the backward-compatibility window, and TMP keys + +Operators SHOULD go further and serve each signing surface from a distinct subdomain. Defense-in-depth: governance keys SHOULD be on offline-rotation (HSM/KMS with manual rotation and human approval), while transport and webhook keys MAY use automated rotation. Operators advertise their separation scheme by publishing an `identity.key_origins` map in `get_adcp_capabilities`; the schema defines `governance_signing`, `request_signing`, `webhook_signing`, and `tmp_signing` origin URIs. `webhook_signing` names the webhook delivery surface, not a required live `adcp_use: "webhook-signing"` purpose; it may point at the same origin as `request_signing` when webhooks use request-signing keys. Implementers SHOULD populate the field so counterparties can verify origin separation at onboarding. **When the field is present, verifiers MUST check that the declared governance-signing origin differs from the declared request/webhook signing origins at onboarding and reject onboarding with a user-actionable error on co-tenancy.** The MUST on origin separation is otherwise unverifiable on the wire — the whole point of publishing the advertisement is to let counterparties enforce it programmatically; accepting a declaration that violates the normative rule would defeat the control. Verifiers MAY additionally fetch each declared JWKS and confirm its `jwks_uri` origin matches the advertised value. + +**Implementer note:** `adcp_use` is a custom JWK member. Major JOSE libraries (`jose`, `node-jose`, `python-jose`, `go-jose`) preserve unknown members on parse. Strict JWK validators (some modes of `PyJWT`, and Web Crypto API's `SubtleCrypto.importKey`) may reject unknown members. When handing a JWK to `SubtleCrypto.importKey` or equivalent strict consumers, strip `adcp_use` from the JWK object but retain it for the step-8 policy check. The field is for AdCP verifier policy, not for cryptographic libraries. + +**JWKS discovery for a signed request** — given a `keyid` on an incoming signature: + +1. The verifier resolves the signing agent's URL to its brand.json `agents[]` entry per [Agent resolution](#agent-resolution). Discovery MAY come from prior onboarding or a registry cache, bound both ways as that section requires, 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. + +**Canonical signed-Agent identity.** Once the publication chain and request signature verify, the canonical form of the exactly matched `agents[].url` is the authenticated Agent subject. That canonical URL—not the operator domain, input spelling, `brand_json_url`, `jwks_uri`, `keyid`, thumbprint, or key generation—is the stable input to a seller-local principal mapping. Distinct canonical Agent URLs remain distinct even when they share a domain, operator, type, or JWKS endpoint. Rotation to another authorized key under the same canonical Agent URL preserves identity. A resolver that starts from a key or cached JWKS mapping and cannot select exactly one canonical Agent URL MUST fail closed; it MUST NOT choose the first entry, collapse entries by domain, or key state by the credential. + +#### Agent resolution + +Under the RFC 9421 profile, every AdCP surface that verifies a signature by agent URL uses this one algorithm: request signing, webhook signing, governance JWS `iss`, designated-task response signing, and rights attestations. Requests and webhooks signed under the Web Bot Auth profile, and JWS artifacts from an issuer whose trust record lists its origin, use [Web Bot Auth identities](#web-bot-auth-identities) instead. Surface sections define their own error codes and MAY add stricter selectors, such as a required `type` or an exact `id`. They MUST NOT define a looser way to match an agent or another way to pick its keys. TMP is not covered: its match-time signatures use publisher keys distributed through the property registry, as the [TMP specification](/dist/docs/3.3.0-beta.3/trusted-match/specification) defines. + +Given agent URL `A`, a verifier first selects the brand.json that must list `A`. There are two selection rules, and each surface uses exactly one of them: + +- **Operator record.** This is the brand.json named by `A`'s own `identity.brand_json_url`, bound to `A` by origin. It is used by request signing, webhook signing, and any surface that does not name another record. Steps 1–4 of [Discovering an agent's signing keys via `brand_json_url`](#discovering-an-agents-signing-keys-via-brand_json_url) select it. +- **Relying-party record.** This is a brand.json the verifier already trusts from context, which lists the agent as acting for that party: the authenticated buyer's brand.json for governance `iss` (see [Buyer identity resolution](#buyer-identity-resolution)), the `brand_domain` brand.json for the [brand-authorization cross-check](#designated-task-response-signing), and the `rights_holder` brand.json for [rights attestations](#rights-grant-attestation-profile-3-2). The surface section says which agent collection inside the document applies (top-level, `house`, or one brand's `agents[]`), and verifiers MUST NOT merge sibling brands' collections. Origin binding does not apply, because the verifier chose the record and not the agent. + +Every surface then applies the same steps to the selected record: + +1. **Match the agent.** Canonicalize `A` and every candidate `agents[].url` with the shared [AdCP URL canonicalization algorithm](/dist/docs/3.3.0-beta.3/reference/url-canonicalization). In an operator record, the candidates are the top-level `agents[]`, or for a House Portfolio, `house.agents[]` together with every `brands[].agents[]`; whatever collection lists `A`, the operator attests it. Because a brand-level `agents[]` replaces the house list, a shared agent can appear in several collections; matches that agree on `type` and on the resolved JWKS source count as one entry. Exactly one entry MUST match canonically and carry the `type` the surface requires, if it requires one. No match and more than one match are both rejections. Verifiers MUST NOT compare URLs byte-for-byte, take the first match, or select an entry by `type` or domain alone. +2. **Find the keys.** The JWKS source is the matched entry's `jwks_uri`. When it is absent, the source is `/.well-known/jwks.json` at the origin of the matched entry's `url`. Verifiers MUST NOT take a JWKS location from the signed artifact, the capabilities response, or `A`'s host independently of the matched entry. +3. **Apply the publisher pin, narrowing only.** This step applies to a signature the agent makes in its sell-side role about inventory of publisher P, such as a webhook about a media buy on P's properties. If P's `adagents.json` entry for this agent carries `signing_keys`, accepted keys are the keys from step 2 whose [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) thumbprint equals the thumbprint of an entry in P's pin. A pinned entry without the complete public-key parameters for its `kty` is invalid and matches nothing; matching by `kid` alone is not sufficient. A `revoked_at` on the pinned entry still applies. When a signed message concerns several publishers' inventory, the key MUST match every pin that applies. The verifier decides which publishers apply from its own record of the media buy (the products and properties it bought), never from fields in the signed payload. A pin never adds a key that step 2 does not publish, never replaces step 2, and never applies to the agent's other traffic. See [Publisher pin precedence](#agent-key-publication). +4. **Fetch safely.** Every fetch in this algorithm follows the SSRF rules in [Webhook URL validation](#webhook-url-validation-ssrf), uses zero HTTP redirects on explicit URLs, parses strictly with duplicate keys rejected, and respects the budgets and cache bounds in the discovery chain below. On a `kid` miss, refetch the JWKS once, subject to the 30-second cooldown between refetches. +5. **Result.** The canonical URL of the matched entry is the verified agent identity. For an operator record, the host of `identity.brand_json_url` is the agent's operator. + +**Shortcuts must be bound both ways (RFC 9421 profile).** Onboarding records, registry caches, and crawler indexes MAY stand in for steps 1–4 of the discovery chain. Such a mapping from `A` to an operator record MUST have been established by, or re-confirmed against, `A`'s `identity.brand_json_url`, and re-confirmed at least as often as the brand.json cache lifetime in step 4 of the discovery chain. If several brand.json files list `A`, only the one `A` points to counts. + +Under the RFC 9421 profile, agent resolution starts with `get_adcp_capabilities` at `A`, even when the agent hosts no other tools. This includes a buyer agent that only calls sellers. Request signing and non-legacy webhook discovery bind the operator record to `A`'s `identity.brand_json_url`. The 3.x legacy webhook fallback may instead use the agent-origin well-known brand.json when that pointer is absent, but still starts with the capabilities call and is never used for request signatures. An onboarding record does not replace the capabilities endpoint, because its mapping must be established or re-confirmed through discovery. A signer that serves nothing at `A` fails discovery step 1 with `request_signature_capabilities_unreachable`. A signer with no capabilities endpoint uses the [Web Bot Auth profile](#web-bot-auth-identities), which needs none. + +#### 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. In this section (apart from the `authorized_operators[]` field), *operator* means the agent's operator: whoever publishes the brand.json that lists the agent and its keys. It isn't the `operator` in an account's natural key. For buyer-declared accounts, that value comes from `accounts[].operator` on `sync_accounts` and `account.operator` on later calls, and one agent can act for many account operators (see [Seller walkthrough: buyer-declared accounts](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#seller-walkthrough-buyer-declared-accounts)). Steps 1–4 below select the operator record for [Agent resolution](#agent-resolution); steps 5–8 apply that section's shared steps with the request-signing error codes. 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. Only a House Portfolio document carries `authorized_operators[]`; for any other variant there is no fallback. Any entry whose `domain` equals `A`'s eTLD+1 satisfies the binding; that entry's `brands` and `countries` scope account authorization, not key resolution. 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 the exact `brand_json_url` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). Verifiers MUST NOT follow HTTP redirects on this explicit-URL fetch. This is distinct from a domain-derived `/.well-known/brand.json` bootstrap and from JSON document-indirection variants. Budgets: connect 5 s and total deadline 10 s (recommended); the body cap of 2 MiB (`MAX_BRAND_JSON_BYTES`, 2,097,152 bytes) is normative. The cap is a streaming limit: the verifier MUST count bytes as they arrive and abort the connection as soon as the count exceeds the cap, never buffer the body and then check its length; MAY reject up front when a `Content-Length` header exceeds the cap, but MUST still enforce the cap while streaming. When the response is content-encoded, the cap applies to the decoded bytes, so a small compressed body cannot expand past it. The total deadline covers reading the whole body, which bounds slow-body connections. The cap applies to brand.json fetches only; the JWKS and capabilities caps (`MAX_JWKS_BYTES`, `MAX_CAPABILITIES_BYTES`) are unchanged. Verifiers MUST NOT read more than 2 MiB, and SHOULD accept any brand.json up to that size. Verifiers SHOULD send `Accept-Encoding: identity`. Parse cost scales with size, so the strict parser MUST also enforce a maximum JSON nesting depth of 32 while parsing (reject deeper documents as `request_signature_brand_json_malformed`; legitimate brand.json is far shallower), and SHOULD bound the total node count and parse off the request hot path. Because a brand.json can now be large, a verifier MUST run its cheap checks (signature-input parse, request-level rate limiting) before fetching brand.json, MUST bound the number of concurrent in-flight trust-root fetches, and MAY negative-cache cap, depth, and duplicate-key failures for the full 60 s so rotating URLs cannot force repeated large fetches. Verifiers SHOULD revalidate with `ETag` / `If-None-Match` when the origin supports it and SHOULD single-flight concurrent fetches of the same URL rather than refetching per request. 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. Canonicalize `A` and every candidate `agents[].url` with the shared [AdCP URL canonicalization algorithm](/dist/docs/3.3.0-beta.3/reference/url-canonicalization), then find entries whose canonical URL equals canonical `A`. The candidates are those in [Agent resolution](#agent-resolution) step 1: the top-level `agents[]`, or for a House Portfolio, `house.agents[]` and every `brands[].agents[]`, with matches that agree on `type` and resolved JWKS source counted once. If none matches, reject with `request_signature_agent_not_in_brand_json`. If multiple entries match after canonicalization, reject with `request_signature_brand_json_ambiguous`; never select the first. Retain the matched canonical URL as the verified Agent identity if the remaining key checks succeed. A trailing slash remains significant under the shared algorithm, while scheme/host case, default ports, dot segments, and percent-encoded unreserved characters normalize as specified there. +6. Resolve the JWKS source from the matched `agents[]` entry's `jwks_uri`, defaulting to `/.well-known/jwks.json` at the origin of that entry's `url` when absent. This holds for every surface and role. For a signature the agent makes in its sell-side role about a publisher's inventory, such as a webhook about a media buy, then apply that publisher's `adagents.json` `signing_keys` pin as a narrowing intersection per [Publisher pin precedence](#agent-key-publication). The pin does not apply to the agent's other traffic and does not imply a separate `adcp_use: "webhook-signing"` key purpose. +7. **`identity.key_origins` consistency check (mandatory when signing).** For every surface/purpose declared under `identity.key_origins` on the capabilities response, the host of the resolved `jwks_uri` MUST equal the declared origin for that surface/purpose. Mismatch on any surface/purpose → reject with `request_signature_key_origin_mismatch` carrying `{ purpose, expected_origin, actual_origin }`. A publisher pin does not skip this check, because the JWKS source is always the operator brand.json. 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)). + +**The capabilities budget binds this path only.** `MAX_CAPABILITIES_BYTES` (64 KiB) is the fetch budget of step 1 of this chain. It belongs to the RFC 9421 profile, which 3.3 deprecates and 4.0 removes. It is not a protocol limit on the `get_adcp_capabilities` response, which is bounded by structure (see [Response Size Bounds](#response-size-bounds)). Outside this chain, the same figure is only a receiver backstop. The fetch has no narrowing lever: it reads the whole `get_adcp_capabilities` response, and the request carries no way to ask for less. A capabilities document above the budget fails with `request_signature_capabilities_unreachable` and the agent's signing keys stop resolving. The [Web Bot Auth profile](#web-bot-auth-identities) is unaffected. Its verifier fetches the key directory at the signer's origin and makes no `get_adcp_capabilities` call, so the size of the capabilities document does not gate signature verification there. An agent whose capabilities cannot stay under 64 KiB, such as a creative agent with a large `creative.supported_formats` catalog, adopts the Web Bot Auth profile or keeps the document under the budget. 3.x does not add a way around the limit to the RFC 9421 profile, and 4.0 removes this fetch budget together with the profile. + +**Key material and key pointers do not belong in capabilities.** The `identity.brand_json_url` pointer exists for this deprecated chain only, and the chain stays as written through 3.x. No new path reads keys, or a pointer to keys, from `get_adcp_capabilities`. A signing path added after 3.3 finds keys through the signer's key directory, as the [Web Bot Auth profile](#web-bot-auth-identities) does, and does not fetch capabilities to do it. A field that carries or points at key material MUST NOT be added to the capabilities response. This keeps the size of the capabilities document from ever gating signature verification on a path that is not already deprecated. + +**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`"). The operator brand.json `jwks_uri` is the source of keys for every signature. For a sell-side signature about a publisher's inventory, a publisher pin can only narrow that set: both parties must agree on a key before it is accepted. 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, nesting deeper than 32, 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 2 MiB body cap. Verifier: do not retry; surface to operations. A verifier still enforcing the pre-3.3 256 KiB cap rejects large brand.json files with this code, so upgrade it to 2 MiB. | +| `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` | Canonical Agent URL does not match any canonical `agents[].url` in resolved brand.json | `agent_url`, `brand_json_url` | Operator: add the Agent URL to `agents[]` or correct a semantically different path, query, or scheme. Not retryable. | +| `request_signature_brand_json_ambiguous` | Multiple `agents[]` entries canonically match the Agent URL | `agent_url`, `brand_json_url`, `matched_count`, `matched_entries[]` | Operator: dedupe `agents[]` entries by canonical 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 mapping from agent URL to the agent's brand.json 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[]` (present only on a House Portfolio) — 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 and enforces the nesting-depth limit while parsing (in Python, the stdlib `json.JSONDecoder` with an `object_pairs_hook` that raises on duplicates; in TS and Go the stock parsers are last-wins on duplicate keys, so scan the token stream for duplicates before or while decoding — `secure-json-parse` guards prototype poisoning only, and `Decoder.DisallowUnknownFields` does not detect duplicates) — 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.** Canonicalize `A` and every candidate `agents[].url` (top-level, or for a House Portfolio `house.agents[]` and every `brands[].agents[]`) with the shared [AdCP URL canonicalization algorithm](/dist/docs/3.3.0-beta.3/reference/url-canonicalization). Reject `request_signature_agent_not_in_brand_json` on no canonical match and `request_signature_brand_json_ambiguous` on multiple canonical matches. Retain the one matched canonical URL as the Agent identity after signature verification succeeds. +6. **Resolve `jwks_uri`** from the matched entry (default: `/.well-known/jwks.json` at the origin of the matched entry's `url`). For a sell-side signature about a publisher's inventory, keep only keys whose RFC 7638 thumbprint matches an entry in that publisher's `adagents.json` `signing_keys` pin, when one exists. +7. **Consistency check.** For every surface/purpose declared under capabilities `identity.key_origins`, apply `canonicalizeOrigin()` (ASCII-lowercase + IDNA-2008 A-label) to both the resolved `jwks_uri` host and the declared origin, then byte-compare. 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; // deprecated RFC 9421 chain only +const MAX_BRAND_JSON_BYTES = 2_097_152; // 2 MiB; brand.json only +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 canonicalAgentUrl = canonicalizeAdcpUrl(agentUrl); + const candidates = brandJson.agents ?? [ // step 5 candidates + ...(brandJson.house?.agents ?? []), + ...(brandJson.brands ?? []).flatMap(b => b.agents ?? []), + ]; + const jwksSource = e => e.jwks_uri ?? `${origin(e.url)}/.well-known/jwks.json`; + const matches = candidates.filter( // step 5 (canonical match) + e => canonicalizeAdcpUrl(e.url) === canonicalAgentUrl, + ); + const distinct = new Map(matches.map( // a shared agent repeated across + e => [`${e.type}|${canonicalizeAdcpUrl(jwksSource(e))}`, e], // collections counts once + )); + if (distinct.size === 0) throw new Err('agent_not_in_brand_json'); + if (distinct.size > 1) throw new Err('brand_json_ambiguous'); + const entry = [...distinct.values()][0]; + const jwksUri = jwksSource(entry); // 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: canonicalAgentUrl, 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`. The Python SDK differs: its record carries `fetched_at` and the raw `key_origins` map instead of the `identity_posture`, `consistency`, and `freshness` fields. + +- **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, *, agent_type)` (sync) and `async_resolve_agent(url, *, agent_type)` return an `AgentResolution` with fields `agent_url`, `brand_json_url`, `agent_entry`, `jwks_uri`, `jwks`, `fetched_at`, `key_origins`, `trace`. `await verify_from_agent_url(request, agent_url, *, agent_type, operation, capability=...)` is the one-shot helper that runs the discovery chain and the [verifier checklist](#verifier-checklist-requests) in one call. +- **Go** ([`adcp-go`](https://github.com/adcontextprotocol/adcp-go)): `ResolveAgent(ctx, agentURL) (*AgentResolution, error)` returns a struct with fields `AgentURL`, `BrandJSONURL`, `AgentEntry`, `JWKSUri`, `JWKS`, `IdentityPosture`, `Consistency`, `Freshness`, `Trace`. `VerifyRequestSignature(ctx, req, opts) (*VerifiedIdentity, error)` mirrors the TS/Python one-shot. + +Each SDK ships a CLI for dev-loop debugging — `npx @adcp/sdk@latest resolve `, `adcp resolve ` (also `python -m adcp resolve `), `adcp resolve ` (Go binary, same name as the Python one — disambiguate by `$PATH` or vendor) — printing the trace with per-step `fetched_at`/`age_seconds`/`ok` so an operator triaging a `request_signature_brand_*` failure can see exactly which step rejected and why. Both the Python (`[project.scripts]` console_scripts entry) and Go (binary `adcp`, distinct from the Go module path `github.com/adcontextprotocol/adcp-go`) toolchains install a top-level `adcp` command so a single muscle-memory invocation works across runtimes. + +#### Web Bot Auth identities + + +**Experimental in 3.3.** This section covers requests and webhooks signed under the Web Bot Auth profile (`web-bot-auth`). The profile's wire rules (the `Signature-Agent` header, signature parameters and algorithms, the key directory, the nonce and replay protection, and the failure statuses) are defined once, on the [Web Bot Auth profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile) page ([#7894](https://github.com/adcontextprotocol/adcp/pull/7894)). This section covers identity, trust records, binding, and authorization, and does not restate them. JWS artifacts (governance context, designated-task response signing, rights attestations) from a Web Bot Auth identity are verified with keys from its key directory, as the JWS artifacts rule under Coexistence below says ([#8299](https://github.com/adcontextprotocol/adcp/pull/8299)). A seller advertises the profile with `web-bot-auth` in `request_signing.profiles` and `webhook_signing.profiles`. Design: [`specs/agent-identity-3-3.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md) R3, R3a, R4 and R9. + + +**Identity is an origin.** A Web Bot Auth identity is the [canonical origin](/dist/docs/3.3.0-beta.3/reference/url-canonicalization#canonical-origin) `O` named by the signature's covered `Signature-Agent` member. One agent per origin; a governance agent is its own agent. + +**Verifying identity.** Identity is established when the signature verifies under a key from `O`'s key directory (`https://O/.well-known/http-message-signatures-directory`), and that key is not revoked in the identity's revocation list. That list is at `O`, at `/.well-known/governance-revocations.json` with the `adcp-gov-revocation+jws` type, and covers all of the identity's keys. [Transport revocation](#transport-revocation) says what a request signed with a revoked key gets, and what happens when the list is missing or stale. + +Discovery failures and key removal follow WBA §6.10. A request the verifier cannot attribute to `O` is unverified, and what the verifier does with it is local policy under the draft: it handles the request as an anonymous caller's, or answers `403` with `Accept-Signature`. + +There is no capabilities fetch and no brand.json lookup, and verification creates no principal. A verified origin the seller has not bound is *identity-established*: it gets what an anonymous caller gets. + +**Evaluating the trust record.** The operator's trust record is evaluated when something is gated on it: binding a principal, evaluating a grant ([Grants in trust records](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental)), or `open` onboarding. It is never evaluated on the request-signature path. For a JWS artifact, step 1 derives it to select the profile, as the JWS artifacts rule under Coexistence below says. + +1. **Derive it.** `R = https://{eTLD+1(host(O))}/.well-known/trust.json`, using a pinned, dated Public Suffix List snapshot that includes the ICANN and PRIVATE sections. + - An `O` whose host is an IP literal or a public suffix, or has no defined eTLD+1, has no trust record. + - Fetch `R` under the [Webhook URL validation](#webhook-url-validation-ssrf) rules, with a 2 MiB streaming body cap and strict parsing (duplicate keys rejected, nesting depth 32). + - Follow at most one `authoritative_location` hop, with zero HTTP redirects. A hop target that itself carries `authoritative_location` is rejected. + - The **operator** is `eTLD+1(host(O))`: never a value inside the document, and never the host of a hop target. +2. **Match.** Exactly one `agents[]` entry has a canonical origin equal to `O` and carries the role the gate requires (`adcp:buying` for a buyer). +3. **Failure.** A failed derivation or match is an authorization outcome. It returns `PERMISSION_DENIED` with no `details`, one externally visible code, under the cross-tenant oracle clamp. The cause goes to logs only. + +**Freshness and operator revocation.** +- A binding is a cache of the trust-record evaluation, not a standing grant. Verifiers MUST re-evaluate it at least as often as the revocation polling interval, and before a spend-committing call when the cached result is older than that. +- A fetch failure is not absence: the last record the verifier read stays authoritative, and spend-committing operations fail closed with the retryable `SERVICE_UNAVAILABLE`. A seller MAY keep serving non-spend operations under the cached evaluation during an outage and SHOULD bound how long. +- An operator revokes an agent by removing its origin from the trust record. That ends every binding and grant that reaches the agent through the operator within one cache lifetime. The origin falls back to identity-established: it gets what an anonymous caller gets, as at first contact. An operator that retires all of its agents publishes an empty `agents[]`. +- Verifiers SHOULD rate-limit, single-flight, and negative-cache trust-record fetches by host, by eTLD+1, and by hop target host, and SHOULD cache negatives for at most 60 seconds. + +**Errors.** Signature failures use the status codes and headers the [Web Bot Auth profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile) page defines. An AdCP error body, when sent, is the generic authentication error with no signature-specific detail. + +Once identity is established, every authorization outcome uses the standard AdCP error envelope with existing codes, under the cross-tenant oracle clamp. A request signed with a revoked key gets the answer in [Transport revocation](#transport-revocation). A correctly signed request never gets `403` with `Accept-Signature`, which tells the signer to sign again. + +**Cross-organization hosting.** A platform that runs an agent for a brand lists the agent in its own trust record, and the brand grants the platform's domain. There is no hosting-delegation fallback. + +**Coexistence with the RFC 9421 profile.** +- **Profile selection.** A signed message that carries `Signature-Agent` or the `web-bot-auth` tag is verified under the Web Bot Auth profile only, never under the RFC 9421 profile. An uncovered `Signature-Agent` member is never attributed to the signature. +- **Unsigned requests.** The answer to an unsigned request for an operation in `required_for` is defined on the [Web Bot Auth profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile) page, including when a verifier advertises both profiles. +- **JWS artifacts.** Spec [R4](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md#r4-coexistence-with-the-32-profile-p3), as amended by [#8299](https://github.com/adcontextprotocol/adcp/pull/8299), says which profile verifies a JWS artifact and what the verifier checks under each. +- **No downgrade.** Once a verifier has bound `O`, it refuses RFC 9421 signatures for any agent URL whose origin is `O`. Before binding, an operator ends RFC 9421 use for an agent by removing its brand.json entry and keys. +- **Grants.** A 200 from a grantor's trust.json makes it the grantor's only source of grants, and brand.json `authorized_operators[]` is ignored, as `specs/brand-identity-trust-split.md` §No downgrade sets out. + +#### Agent identity + +Under the RFC 9421 profile, a valid signature establishes exactly one fact: **the request was issued by the agent whose `jwks_uri` contains the `keyid`.** (Under the Web Bot Auth profile, the identity is the signer's origin; see [Web Bot Auth identities](#web-bot-auth-identities).) 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. That's the agent's operator, not the account operator a buyer names in `account.operator`. + +**`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 the account operator named in the request 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 at most one `authoritative_location` **document-indirection variant** and stops. The explicit `authoritative_location` URL itself uses zero HTTP redirects. + +#### Verifier checklist (requests) + +**Before applying the checklist, verifiers MUST determine whether the operation requires a signature:** + +- Resolve the AdCP operation per [Operation resolution over A2A](#operation-resolution-over-a2a); reject with `request_body_malformed` if an A2A message does not resolve to exactly one. +- If the resolved 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. Under the 3.2 profile, also reject if `content-digest` is not covered. A verifier operating an explicitly configured 3.0/3.1 compatibility endpoint applies its advertised legacy mode instead: `"required"` rejects missing coverage, while `"forbidden"` rejects present coverage 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 → canonical `agents[].url` match → `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. The JWKS source is the matched entry's `jwks_uri`, defaulting when absent to `/.well-known/jwks.json` at the origin of that entry's `url`, narrowed by a publisher pin only as [Agent resolution](#agent-resolution) step 3 allows. 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 under exactly one canonical `agents[].url`; a domain, JWKS URI, or key alone is not an Agent identity. +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, where `agent_url` is the canonical URL of the exactly matched `agents[]` entry, so downstream code — including the principal resolver and subsequent brand-operator authorization check — can log, map, and audit by signed Agent identity without treating the credential as the owner. + +**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` remains non-rejecting only for unsigned requests and well-formed signatures that fail verification or body binding when an independent authenticator succeeds. Those failures establish no signer identity. A partial or malformed `Signature` / `Signature-Input` pair still hard-rejects regardless of `warn_for` or fallback credentials, because it cannot be safely interpreted as either signed or unsigned traffic. + + +**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` rather than `required_for`. The verifier records missing signatures and well-formed verification or body-binding failures, establishes no signer identity from them, and continues only under independent authentication. Partial or malformed signature-header pairs still hard-reject. + +*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. + + +AdCP 3.2 verifiers that advertise request-signing support MUST advertise `covers_content_digest: "required"`. Every accepted signature on a request with a body MUST include and cover `content-digest`; this applies uniformly before the verifier trusts the body enough to classify an operation. The `"either"` and `"forbidden"` values are deprecated compatibility postures for 3.0/3.1 endpoints only and are removed in 4.0. Infrastructure that rewrites bodies MUST use a separately configured legacy endpoint/origin or disable request signing under 3.2. + +The endpoint's trusted configuration and negotiated capability select this policy before body parsing or dispatch. An `adcp_version`, operation name, or JSON-RPC method inside an unbound body MUST NOT downgrade a 3.2 verifier to a legacy mode. A shared endpoint that advertises 3.2 applies the 3.2 rule to signed body-bearing traffic; operators needing a legacy posture use a separately configured endpoint or origin. A 3.2 signer SHOULD cover `content-digest` even when a legacy peer advertises `"either"`; an explicit legacy `"forbidden"` posture is incompatible with 3.2 capability negotiation rather than a reason to retry without binding. + +**`"required"` is strict.** A signed request with a body that does not cover `content-digest` is rejected with `request_signature_components_incomplete`. A digest mismatch uses `request_signature_digest_mismatch`; changing both the body and digest invalidates the signature. A client MUST NOT automatically retry unsigned or without digest coverage after either failure. Requests with no body do not require `content-digest`. + +#### 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 + +Under the RFC 9421 profile, 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. A Web Bot Auth identity serves its list at its own origin ([Web Bot Auth identities](#web-bot-auth-identities)). + +Under the Web Bot Auth profile: + +- A request signed with a revoked key gets the generic authentication error (`AUTH_INVALID`) without `Accept-Signature`: the signer must stop using that key, so a hint to sign again would be wrong. +- An identity whose revocation list the verifier has never read is identity-established; its spend-committing operations fail closed with `PERMISSION_DENIED` and no `details`, as a failed trust-record match does. Once the verifier has read a list for `O`, a failed fetch, a `404` included, falls under the next bullet. +- A list the verifier has not refreshed within `next_update + grace` fails spend-committing operations closed with the retryable `SERVICE_UNAVAILABLE`, as a trust-record fetch failure does; `request_signature_revocation_stale` stays with the RFC 9421 profile. +- A verifier keeps every revocation it has seen for `O`; a later list cannot un-revoke a key. + +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), under either profile. The fetch-failure safe-default applies with grace = 4× the previous polling interval. Under the RFC 9421 profile, 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. + +**Revocation and committed work.** These rules apply under both signing profiles. +- **Committed work stands.** A media buy the seller accepted while the key verified stays in force. A seller MUST NOT pause, cancel, or otherwise unwind a buy on revocation alone, because a compromised origin could otherwise stop a buyer's campaigns. +- **Exposure.** A request verified under a key revoked with `reason: keyCompromise` is **suspect** if the seller received it at or after the key's `invalidity_date`, or at any time when there is no date. The comparison uses the seller's receipt time, never the signature's `created`, which the signer controls. A key revoked for any other reason, or with no detail, exposes nothing. +- **Showing the buyer.** A seller SHOULD show the buyer the buys it accepted under suspect requests, by a means of its choice. To make that possible, a seller SHOULD retain the `verified_signer` it records for each verified request (see the [verifier checklist](#verifier-checklist-requests)), together with the request's receipt time, alongside the buys that request created or changed. The buyer resolves each buy with the existing `control_media_buy` actions (`paused`, `canceled`), signed with a current key. +- **The seller's job is the key check.** The seller checks the signing key when a request arrives, and that is its whole job. A buyer that knows its key leaked lists its buys with [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys) and cancels what it didn't send. AdCP adds no standard per-buy flag or reaffirmation for this ([#8158](https://github.com/adcontextprotocol/adcp/issues/8158)). +- **Governed buys.** A revoked governance key stops new delivery-phase tokens until the governance agent issues a fresh one, as in [Revocation](#revocation). + +#### AdCP checks after Web Bot Auth verification + + +These checks belong to the [Web Bot Auth request-signing profile](/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile), which is experimental in AdCP 3.3, adopted by [DR-0023](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0023-agent-identity-is-an-origin.md). + + +A Web Bot Auth (WBA) verifier configured with the profile's rules decides whether a request is validly signed and which origin signed it. AdCP adds two checks that run after that verifier returns, each on a different outcome. The revocation-list check runs only after verification succeeds. The `Accept-Signature` field goes only on the verifier's `403` or `429` refusal. A standard WBA library does not perform them, so an implementation runs them in its AdCP layer. The WBA profile's conformance vectors do not grade them. + +**Revocation list.** After WBA verification succeeds, the verifier checks the signing identity's revocation list, the list that [Transport revocation](#transport-revocation) defines. The following rules apply under the profile: + +- The identity's origin serves the list, with the path and media type that Transport revocation uses. The verifier accepts the list only from that origin. +- A key in the identity's key directory signs the list, with the JWS `alg` `EdDSA`. +- Entries in `revoked_kids` are JWK thumbprints. The verifier matches them against the signature's `keyid`. + +[Transport revocation](#transport-revocation) states the polling cadence and what a request gets when its key is revoked or the list is missing or stale. + +The list lives on the same origin as the key directory. An outage that keeps a stale directory in use also leaves the list unrefreshed, so the safe default bounds how long a removed key commits spend during a discovery failure. + +**`Accept-Signature` on a refusal.** When WBA verification refuses a request with `403` or `429`, the verifier SHOULD add an `Accept-Signature` field (RFC 9421 section 5.1). WBA section 5.3 lets an origin request a signature this way. The field names the label, the covered components, and the parameters the profile requires: + +```http +NOTE: '\' line wrapping per RFC 8792 + +HTTP/1.1 403 Forbidden +Accept-Signature: sig1=("@method" "@target-uri" "@authority" "content-type" \ + "content-digest" "signature-agent";key="sig1");created;expires;nonce;ke\ + yid;alg;tag="web-bot-auth" +``` + +On a `429`, the field SHOULD also carry a fresh `nonce` for the signer's next attempt. The verifier SHOULD add the same field to its answer to an unsigned request for an operation in `required_for`. A `400`, a revoked key, and an AdCP authorization error carry no `Accept-Signature`. + +#### 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": "required", + "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`: `"required"` for every 3.2 signing profile. `"either"` and `"forbidden"` remain decodable only for explicit 3.0/3.1 compatibility and MUST NOT be negotiated as a 3.2 signing posture. +- `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 and records failures in monitoring. Used as a shadow-mode bridge from `supported_for` to `required_for`. Precedence: `required_for > warn_for > supported_for`. A missing signature or a well-formed signature that fails verification or body binding MUST NOT establish verified-signer identity; processing may continue only when an independent bearer, API-key, or mTLS authenticator succeeds. A partial or malformed `Signature` / `Signature-Input` pair always hard-rejects. Verifiers MUST NOT convert a failed signature into an authenticated signer merely because the operation is in shadow mode. +- `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 and relies on independent authentication for that traffic. 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/3.1, verifiers ship with `required_for: []` and populate it selectively. In 3.2, signing remains optional and the operation/protocol-method lists still decide whether signing is supported, shadowed, or required; they no longer alter the cryptographic component set. For `warn_for`, missing or invalid body binding is recorded but MUST NOT establish verified-signer identity, and processing may continue only under independently valid authentication. For `supported_for` and `required_for`, a presented body-bearing signature without digest coverage rejects. In 4.0, `required_for` additionally includes every supported spend-committing operation. + +#### 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. + +The machine-readable authority is [`request-signing-error-code.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/enums/request-signing-error-code.json). SDKs MUST consume that schema's `enumMetadata` recovery classifications instead of inferring behavior from a code's spelling or parsing this prose table. These lowercase transport-profile codes are deliberately separate from the uppercase task error vocabulary in `error-code.json` because they are carried in `WWW-Authenticate`, not task `errors[]` arrays. + +| Failure | Retry? | Code | +|---|---|---| +| Unsigned request where signing is required — either (a) operation is in `required_for`, or (b) **deprecated in 3.x, removed in 4.0:** 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); condition (b) applies only while `legacy_hmac_fallback` is `true`) | 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, or an A2A message does not resolve to exactly one AdCP operation (parser-differential vectors) | 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 + + +**Deprecated in 3.3.** The RFC 9421 webhook profile described here (`adcp/webhook-signing/v1`, keys through brand.json) is deprecated in 3.3 together with the request profile. It stays fully functional through 3.x and is removed in 4.0. Under the experimental Web Bot Auth profile, a webhook is an ordinary Web Bot Auth request from the seller's origin ([Web Bot Auth identities](#web-bot-auth-identities)); advertising it in `webhook_signing.profiles` depends on [#7894](https://github.com/adcontextprotocol/adcp/pull/7894). + + +A **webhook registration** is an object in which a buyer gives the seller a `url` to deliver webhooks to. These are the webhook registrations, and every rule on this page that names a webhook registration applies to each of them: + +- `push_notification_config` (push-notification webhooks); +- `accounts[].notification_configs[]` (account-level webhooks); +- `sync_principal.configuration.notification_configs[]` and `sync_agent_notification_configs.notification_configs[]` (agent-level webhooks); +- `reporting_webhook` (delivery reports); +- `create_media_buy.artifact_webhook` (content artifacts). + +On `reporting_webhook` and `artifact_webhook`, `authentication` is required on a request served below 3.3. + +Deliveries to a webhook registration, and similar asynchronous seller-initiated callbacks, are signed under a symmetric variant of this profile, or as ordinary Web Bot Auth requests from the seller's origin. Role direction is inverted relative to request signing: the **seller signs outbound**, the **buyer verifies**. The HMAC fallback described in [Webhook Security](#webhook-security) is deprecated. + +**Baseline with programmatic advertisement.** Signed webhooks are baseline-required for any seller that emits webhooks, under either signing profile — 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, + "delivery_retry_horizon_seconds": 86400 + } +} +``` + +- `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 `authentication.credentials` on a webhook registration. `false` is the recommended posture in 3.x. It is a discovery flag: a buyer checks it before asking for legacy signing, and it does not select the mode. +- `delivery_retry_horizon_seconds`: maximum interval from the first attempt during which the seller can retry one delivery. Required for `supported: true` in 3.2; minimum 24h, maximum 7 days. Receivers retain the immutable delivery-key binding and terminal publication proof for at least this interval. + +**Mode selection is a switch, not both.** The buyer selects the signing mode in the webhook registration it sends, and the mode applies to every webhook delivered under that registration. With `authentication` present, the buyer asks for legacy HMAC-SHA256 (or Bearer). With `authentication` absent, the seller signs under RFC 9421 with the profile the registration's `signing_profile` names (`adcp/webhook-signing/v1` when it is omitted). A seller whose `legacy_hmac_fallback` is `false` rejects a registration that carries `authentication` with `UNSUPPORTED_FEATURE` and points `error.field` at the block. 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. + +**Reporting and artifact webhooks.** On a request served below 3.3, `reporting_webhook` and `artifact_webhook` must carry `authentication`, so the rejection above and condition (b) apply to them only from 3.3. + +**Key publication.** Signing keys are published by the seller in its **own brand.json** `agents[]` entry at the signing agent's operator domain, at the `jwks_uri` member of that entry — the same publication pattern as any other AdCP agent key. Webhooks are signed with the agent's **`adcp_use: "request-signing"`** key; there is no separate webhook key purpose. Domain separation between requests and webhooks is carried by the signature `tag` (`adcp/request-signing/v1` vs `adcp/webhook-signing/v1`), not by the key purpose. Each signing JWK MUST declare: + +| Member | Value | +|---|---| +| `use` | `"sig"` | +| `key_ops` | `["verify"]` | +| `adcp_use` | `"request-signing"` | +| `kid` | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` | +| `alg` | `"EdDSA"` or `"ES256"` | + +**Key isolation is optional, via a distinct `kid` — not a distinct purpose.** An operator that wants its webhook traffic signed by separate key material (so a webhook-key compromise does not extend to request signing, or to rotate the two independently) publishes a **second `adcp_use: "request-signing"` key with a distinct `kid`** and signs webhooks with that one. Both keys carry the same `adcp_use`; the verifier resolves the right one by the `kid` in `Signature-Input`. No dedicated webhook key purpose is required to achieve isolation. + +> **Deprecated:** `adcp_use: "webhook-signing"` is deprecated and scheduled for removal in a future major version (tracked in [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); the exact window is a WG/RFC decision). Verifiers MUST still accept it for backward compatibility (a webhook signed under a `"webhook-signing"` key verifies cleanly), but new signers SHOULD publish and sign with `"request-signing"` keys only. + +A buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"request-signing"` (or the deprecated `"webhook-signing"`), and MUST reject any other key-purpose failure — any other `adcp_use` value, absent `adcp_use`, or a missing `verify` key_op — with `webhook_signature_key_purpose_invalid`. The reverse is still forbidden: request verification requires `adcp_use == "request-signing"` exactly (a request signature is rejected if the key declares any other purpose), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The webhook path is permissive on key purpose because it already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. + +**Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions within the same JWKS are forbidden so each `kid` resolves to exactly one key. Webhooks are signed with the agent's `request-signing` key, so by default a request-signing-key compromise extends to webhooks. An operator that needs blast-radius separation publishes a second `request-signing` key with a distinct `kid` dedicated to webhook delivery and signs webhooks with it — isolation comes from separate key material under a separate `kid`, not from a separate `adcp_use`. + +**Covered components** are identical to request signing: `@method`, `@target-uri`, `@authority`, `content-type`, and `content-digest`. `content-digest` is REQUIRED on webhook callbacks — the body carries the event, and webhook receivers are buyer-controlled endpoints where body preservation is the buyer's own infrastructure problem. There is no `covers_content_digest: "forbidden"` opt-out for webhooks; transports that cannot preserve webhook body bytes MUST be fixed. + +**Signature parameters** are identical to request signing with one override: + +| Parameter | Notes | +|---|---| +| `created`, `expires`, `nonce`, `keyid`, `alg` | Same semantics as [request signing parameters](#adcp-rfc-9421-profile). | +| `tag` | MUST be exactly `adcp/webhook-signing/v1`. Verifiers MUST reject `adcp/request-signing/v1` on a webhook route with `webhook_signature_tag_invalid`. The distinct tag prevents a request signature from being replayed as a webhook signature and vice versa. | + +**JWKS discovery.** The buyer knows the seller's agent URL `A` from the AdCP integration it's already using. The buyer resolves `A` with [Agent resolution](#agent-resolution), using the operator record named by `A`'s `identity.brand_json_url`. That field is required whenever `webhook_signing.supported` is `true`. + +1. Select the operator record, match `A` to exactly one `agents[]` entry by canonical URL, and run the `identity.key_origins` consistency check, as in steps 1–7 of [Discovering an agent's signing keys via `brand_json_url`](#discovering-an-agents-signing-keys-via-brand_json_url). Buyers MUST NOT compare URLs byte-for-byte. **3.x legacy fallback:** when the seller's capabilities omit `identity.brand_json_url` (a seller built before the field existed), the buyer MAY use `/.well-known/brand.json` at `A`'s host, or at its eTLD+1 when the host serves none, instead. On the fallback path, discovery steps 2, 3, and 7 do not apply, because there is no `brand_json_url` and `key_origins` requires it. A seller that declares `webhook_signing.supported: true` without the field is non-conformant; the fallback exists only so 3.x buyers keep working with such sellers until 4.0. That domain-derived fetch may follow only the original hostname ↔ exact `www` counterpart on HTTPS at the original port for at most 3 hops, and document resolution follows at most one `authoritative_location` or `house` variant, with zero HTTP redirects on an explicit `authoritative_location` URL. When `identity.brand_json_url` is present, buyers MUST use it and MUST NOT fall back. +2. Fetch that entry's `jwks_uri` (or default to `/.well-known/jwks.json` at the origin of the entry's `url`) 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. +3. When the webhook concerns a media buy on inventory whose publisher's `adagents.json` pins `signing_keys` for this seller, keep only keys whose thumbprint matches that pin ([Agent resolution](#agent-resolution) step 3). The buyer takes the publishers from its own record of the media buy, never from the webhook payload. +4. Resolve `keyid` on the incoming `Signature-Input` to a JWK in the resulting 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. + +Every resolution failure in steps 1–3 (a missing or unreachable record, origin mismatch, no match or an ambiguous match, or a key outside the pin) rejects with `webhook_signature_key_unknown`. Buyers SHOULD log the specific cause using the matching `request_signature_*` code name from the [discovery chain](#discovering-an-agents-signing-keys-via-brand_json_url). + +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. A publisher pin can narrow the accepted keys but never identifies the signer. Identity is established solely via the signature → JWKS → seller `agents[]` entry chain. + +**Downgrade and injection resistance.** The buyer states its webhook-signing preference by the presence or absence of `authentication` on the webhook registration, in the inbound request that registers the webhook. That 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 damage until 4.0 removes the block. On `reporting_webhook` and `artifact_webhook`, they apply only to a request served at 3.3 or later, because a request served below 3.3 must carry the block: + +- **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 whose `legacy_hmac_fallback` is `true` and that support request signing MUST require** the inbound request to be 9421-signed (per the [request verifier checklist](#verifier-checklist-requests)) when any webhook registration in the request carries `authentication`, rejecting with `request_signature_required` (the same code used for `required_for` operations — see [Transport error taxonomy](#transport-error-taxonomy)). This is condition (b), deprecated in 3.x and removed in 4.0. Sellers with `legacy_hmac_fallback: false` reject the registration with `UNSUPPORTED_FEATURE` instead. Signing the request cannot make that registration succeed. 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 the signing mode doesn't match the registration. A mismatch is a 9421-signed webhook after registering with `authentication.credentials`, or an HMAC-signed webhook 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. + +**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 **three profile substitutions** — the `tag` value (`adcp/webhook-signing/v1` instead of `adcp/request-signing/v1`), the direction-of-trust resolution (seller's brand.json `agents[]` entry instead of the buyer's), and webhook v1's legacy unpadded Base64URL binary-value decoder instead of the 3.2 request profile's RFC 8941 Base64 decoder. Parser mode comes from the trusted webhook route/profile configuration before header parsing; it is not a lenient fallback after request-profile parsing fails. 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 three profile parameters, 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. + +**Before step 1: bound the body.** Receivers SHOULD apply the [inbound body bounds](#inbound-body-bounds) — the byte cap (4 MiB default), the depth-64 and 100,000-member parse bounds, and the connect, idle, and total deadlines — before running step 1, since the checklist parses signature parameters and computes `content-digest` over the full body before it reaches step 14. An over-limit request is rejected with HTTP 413 and is not one of the 15 checks below or a `webhook_*` code. + +1. Parse `Signature-Input` structure per RFC 9421 §4 and decode the `Signature` binary token with the webhook-v1 unpadded Base64URL profile. Reject if malformed (`webhook_signature_header_malformed`). If `Signature` or `Signature-Input` is present without the other, reject with the same code — a bound pair, not a guessable one. +2. Reject if any of `created`, `expires`, `nonce`, `keyid`, `alg`, or `tag` is absent from the `Signature-Input` parameters (`webhook_signature_params_incomplete`). +3. Reject if `tag` is not exactly `adcp/webhook-signing/v1` (`webhook_signature_tag_invalid`). Byte-for-byte match; no case-folding. +4. Reject if `alg` is not in the allowlist (`ed25519`, `ecdsa-p256-sha256`). Library defaults MUST NOT be relied upon (`webhook_signature_alg_not_allowed`). +5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`webhook_signature_window_invalid`). +6. Reject if covered components do not include ALL of: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest` (`webhook_signature_components_incomplete`). `content-digest` is REQUIRED; there is no policy branch. +7. Resolve `keyid` to a JWK via the JWKS discovery steps above. On `kid` miss, refetch once (30-second cooldown between refetches) before rejecting (`webhook_signature_key_unknown`). Reject if `keyid` cannot be resolved to a specific `agents[]` entry in the signer's brand.json. +8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is `"request-signing"` — webhooks are signed with the agent's request-signing key (see [Key publication](#webhook-callbacks)); the deprecated `"webhook-signing"` value MUST also be accepted for backward compatibility. Reject on any other outcome with `webhook_signature_key_purpose_invalid`: absent `adcp_use`, a missing `verify` key_op, or any other `adcp_use` value (e.g. `"response-signing"`, `"governance-signing"`). Accepting `"request-signing"` here is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. (`webhook_mode_mismatch` is reserved for the HMAC-vs-9421 auth-mode selector mismatch — see [Downgrade and injection resistance](#webhook-callbacks) — and is NOT used for key-purpose failures.) +9. Check the [Transport revocation](#transport-revocation) list (reused across signing purposes). Reject if `keyid ∈ revoked_kids` (`webhook_signature_key_revoked`). Reject with `webhook_signature_revocation_stale` if the verifier has not refreshed within grace. + + **9a. Per-keyid cap check.** Check the [webhook replay-cache cap](#webhook-replay-dedup-sizing). Reject with `webhook_signature_rate_abuse` if exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing. + +10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying `@target-uri` canonicalization AND `@authority` derivation per [the request-signing profile](#adcp-rfc-9421-profile). **The `@authority` rule is load-bearing for webhook security:** verifiers MUST derive `@authority` from the HTTP/2+ `:authority` pseudo-header when present, otherwise from the as-received HTTP/1.1 `Host` header — NOT from reverse-proxy routing state, load-balancer metadata, or any `Host` value a forward proxy may have rewritten in transit. If both `:authority` and `Host` are present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects with `webhook_target_uri_malformed`. The canonicalized `@authority` MUST byte-for-byte match the authority component of the canonical `@target-uri`; mismatch rejects with `webhook_target_uri_malformed`. That byte-match against the signed `@target-uri` — not the choice of source header — is the only safe gate, because `Host` itself can be rewritten in transit. Implementers building from this checklist alone — without cross-referencing the profile — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated webhook and replays it to a second vhost on the same verifier pool: same cert SAN, different `Host`). After canonicalization completes, verify the signature against the JWK (`webhook_signature_invalid` on failure). +11. Recompute `content-digest` from the received body bytes and compare (`webhook_signature_digest_mismatch` on mismatch). REQUIRED — no policy branch. +12. Check the nonce against the replay cache. Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`webhook_signature_replayed`). +13. **Only after steps 1–12 have all passed**, insert `(keyid, nonce)` into the replay cache with TTL = `(expires − now) + 60 s`. This insert MUST happen before the body-well-formedness check at step 14 so that a captured frame carrying a valid signature over a malformed body cannot be replayed to burn crypto-verify CPU on each retry — the nonce is burned on first sighting of a cryptographically-valid frame, regardless of body shape. The load-bearing cap invariant this ordering preserves is documented after step 14b. +14. **Body well-formedness.** Verifiers MUST reject bodies containing duplicate object keys (`webhook_body_malformed`). Per RFC 8259 §4, duplicate-key parse behavior is unpredictable — the signature is valid over the bytes on the wire, but two parsers can disagree on the parsed value, which is a parser-differential attack class (cf. CVE-2017-12635). This check closes the gap between the signature verifier's view of the payload and the downstream consumer's view. A verifier that crashes rather than returning a structured `webhook_body_malformed` error is conformant-but-suboptimal — senders receive no actionable error code. The conformance fixture for this check is the `duplicate-keys-conflicting-values` vector in `static/test-vectors/webhook-hmac-sha256.json` — the 9421 profile MUST apply the same body-well-formedness rule after signature verification succeeds. `webhook_body_malformed` is distinct from `webhook_signature_digest_mismatch`: the signature IS valid; the body parses to ambiguous state. + + **14a. Strict-parse requirement.** The check MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. Query libraries that happily return a value on duplicate-key input without surfacing the collision also do not satisfy this requirement, regardless of marketing as "safe" or "strict" (cf. `tidwall/gjson` in Go — a query library, not a validator). Per-language strict-parse escape hatches, canonical non-exhaustive list: + - **Python**: stdlib `json.loads(..., object_pairs_hook=...)` — detect duplicates inside the hook and raise. Satisfies the check. + - **Node**: no strict mode in `JSON.parse`. Use a streaming parser (`stream-json`, `jsonparse`) with a duplicate-key event handler. `secure-json-parse` is NOT sufficient by default: its protections target prototype-pollution keys (`__proto__`, `constructor`), not data-key duplicates, which it still collapses last-wins. Configure it to reject data-key duplicates explicitly or layer a streaming parser underneath. + - **Go**: `encoding/json` has no strict mode and does not detect duplicates. Use `json.Decoder` token-walk with an explicit `map[string]struct{}` unique-key guard per object scope, OR `goccy/go-json` with `decoder.DisallowDuplicateKey()` explicitly enabled (NOT the default). Do NOT use `tidwall/gjson` for this check — it is a query library that returns the last value on duplicate-key input without signaling the collision. + - **Java**: Jackson `DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY` (disabled by default, enable explicitly). + - **Ruby**: stdlib `JSON.parse` has no detection hook. Use `Oj.load(..., mode: :strict)` with the `allow_nan: false` / duplicate-rejection options explicitly configured. + + **14b. Logging discipline.** Verifiers SHOULD NOT log full request body bytes on a `webhook_body_malformed` rejection; log `keyid`, nonce, byte length, and the specific duplicate key names only. An attacker holding a compromised signer key can otherwise force attacker-chosen bytes into defender logs at scale, burning a replay-cache slot per frame but leaving an attacker-controlled log trail for SIEM poisoning or credential exfiltration follow-on attacks. When logging duplicate key names, verifiers MUST sanitize each name with the following rules applied in order: + + - **(a) Truncate at the first non-printable codepoint** and emit `` where N is the byte length of the truncation prefix. This elides position information (the placement of a non-printable within the key name would otherwise itself be an attacker channel, encodable as bit positions) while preserving the "something was wrong here" diagnostic signal. The non-printable set MUST include at minimum: **C0 controls** (U+0000–U+001F), **DEL** (U+007F), **C1 controls** (U+0080–U+009F, terminal control semantics in multi-byte form), **bidi controls and isolates** (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 — reverse rendering in terminals and SIEM UIs), **line and paragraph separators** (U+2028, U+2029 — render as line breaks in many log viewers, enabling row-injection), **zero-width characters** (U+200B–U+200D — invisible obfuscation), and the **byte-order mark** (U+FEFF — parser corruption). Implementations MAY extend the set to a broader Unicode non-printable classification but MUST NOT narrow it — an ASCII-only check misses bidi-override and line-separator attacks that reopen exactly the log-injection channel this rule exists to close. + - **(b) Truncate to at most 32 bytes** at the last complete UTF-8 codepoint boundary. Realistic AdCP field names top at roughly 24 characters (`signed_authorized_agents`), so 32 is a generous cap while still bounding the attacker-controlled-byte surface. Truncation MUST occur at the last complete UTF-8 codepoint boundary at or below 32 bytes so multi-byte sequences are not split mid-codepoint and invalid-UTF-8 does not land in logs (different verifiers truncating the same input to different invalid-UTF-8 tails would also break log aggregation). + - **(c) Cap the number of duplicate key names logged per rejection at 4**, emitting `<...N more>` if exceeded. Diagnostic value of knowing 4 vs 8 vs 16 colliding keys is near zero. + + Without these constraints, the key-name channel remains an attacker-controlled-byte side channel — smaller than full-body logging but non-zero, and well-precedented as a log-injection vector. Signers that log upstream-input rejections (see [the duplicate-object-keys signer-side rule](#legacy-hmac-sha256-fallback-deprecated-removed-in-40)) MUST apply the same (a)/(b)/(c) sanitization rules to any key names surfaced in signer-side error output; the channel shape is identical even though the wire direction is inverted. + +**A load-bearing invariant for the webhook cache.** External traffic without the signer's private key cannot grow this cache: every entry admitted at step 13 has already passed step 10's cryptographic verification, so any party driving cache growth is either the legitimate key holder or someone who has compromised the key — the case the per-keyid cap (step 9a) and the new-keyid admission-pressure alarm (see [Webhook replay dedup sizing](#webhook-replay-dedup-sizing)) are designed to detect. The invariant mirrors the [analogous request-signing rule](#verifier-checklist-requests) (see the "load-bearing invariant for the cap" paragraph immediately after step 13 there). Future edits to the webhook checklist MUST preserve this ordering: moving the step 13 insert before step 10's signature verification would let any external party flood the cache using forged structurally-valid signatures. + +There is no subsequent brand-operator authorization step on the webhook path — the signature establishes the seller's identity, and that identity is sufficient to accept the webhook. Application-layer dedup on `idempotency_key` runs after signature verification (step 13) to protect against duplicate side effects. + +**One signature per webhook.** Verifiers MUST process exactly one `Signature-Input` label and ignore additional labels. + +##### Webhook replay dedup sizing + +Replay dedup for webhooks reuses the `(keyid, nonce)` key shape and TTL semantics from [Transport replay dedup](#transport-replay-dedup), but the buyer-side cache sees signatures from every seller the buyer integrates with — fundamentally different fan-in from the request-side case. + +- **Per-keyid entry cap**: recommended 100,000 entries (10× lower than the request-side 1,000,000 ceiling). A seller emitting 100K unique webhooks in a 6-minute window is 275/sec sustained from a single signer — plenty of headroom for normal operations and still a strong signal of misconfiguration or key compromise. +- **Aggregate cache cap**: recommended `min(aggregate_memory_budget, 10,000,000)` entries across all signers. On aggregate-cap exceeded, verifiers MUST reject new signatures with `webhook_signature_rate_abuse` and SHOULD alert operators — silent eviction creates replay windows precisely when the verifier is under attack. +- **Per-seller budget**: operators SHOULD budget per-seller by integration criticality rather than equal-weighting all sellers at 100K each. A spend-committing seller's webhook fan-in differs from a discovery-only seller's. +- **New-keyid admission pressure** (MUST track, SHOULD alert). Verifiers MUST track the rate of cache entries admitted from previously-unseen `keyid`s per unit time (e.g., a 5-minute rolling count of distinct `keyid`s inserting their first entry). A sudden spike in new-keyid admission rate is the signature of a **distributed-compromise attack**: an attacker holding N compromised signer keys can drive N entries per TTL window each, every key staying well within its per-keyid cap (step 9a), while collectively saturating the aggregate cache. Each key's traffic individually looks like a low-volume legitimate signer; the aggregate shape is the signal. + + Verifiers SHOULD alert when new-keyid admission exceeds **any** of four thresholds (whichever triggers first), each closing a distinct attacker pattern: + + - **(a)** a **short-window ratio threshold** comparing the current admission rate against a short-horizon moving-average baseline — catches sudden spikes against a stable baseline. + - **(b)** a **medium-window ratio threshold** against a medium-horizon percentile baseline — catches multi-week ramp-up attacks, whose traffic is dominated by the baseline tail at that horizon. + - **(c)** a **long-window ratio threshold** against a long-horizon percentile baseline — catches multi-month ramp-up attacks that drift the medium-horizon anchor with them. + - **(d)** a **proportional ceiling** combining an absolute floor with a fraction of the unique-keyid count over a documented window — catches sparse-traffic verifiers whose ratio baselines are near zero, AND auto-scales to operators of any size (small verifiers get a low proportional floor; enterprise verifiers get a proportionally larger one). + + **The four categories are normative; the concrete threshold values are NOT.** Operators MUST treat any published example values as starting points, baseline their own traffic, and tune accordingly — published normative threshold numbers would hand attackers an oracle into the detection posture. Concrete starting values, baselining methodology, and attack-scenario walkthroughs are published in the non-normative [Webhook Verifier Tuning Guide](/dist/docs/3.3.0-beta.3/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. 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.3.0-beta.3/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`, `request-signing`}, absent, or `key_ops` lacks `verify` | `webhook_signature_key_purpose_invalid` | +| `keyid` ∈ `revoked_kids` | `webhook_signature_key_revoked` | +| Revocation list not refreshed within grace | `webhook_signature_revocation_stale` | +| Cryptographic verification failed | `webhook_signature_invalid` | +| `content-digest` mismatch | `webhook_signature_digest_mismatch` | +| Body contains duplicate object keys (parser-differential attack class) | `webhook_body_malformed` | +| `@authority` does not match signed `@target-uri` authority component (cross-vhost replay) | `webhook_target_uri_malformed` | +| Nonce already seen within window | `webhook_signature_replayed` | +| Per-keyid replay cache exceeded cap | `webhook_signature_rate_abuse` | +| Registered auth mode does not match signature mode on received webhook | `webhook_mode_mismatch` | + +**Retry semantics for verification failures.** At-least-once delivery tells senders to retry on any non-2xx response, but a verification failure is not a transient error — the signature bytes and request context arrive identically on every retry, so every retry fails identically. Senders MUST treat a `401` response carrying `WWW-Authenticate: Signature error="webhook_*"` (any code defined in the taxonomy above, including `webhook_signature_*`, `webhook_target_uri_malformed`, and `webhook_mode_mismatch`) as a terminal failure for that specific delivery attempt: stop retrying the current event, log the failure with the error code for operator attention, and continue the normal retry queue for subsequent events. Senders SHOULD route sustained `webhook_*` error rates above an operator-defined threshold to incident response rather than continuing to emit them — persistent signature, authority, or mode failures indicate a key-rotation coordination problem, a misconfigured verifier, or a compromise, all of which need human action. Receivers MUST NOT silently discard these failures; surfacing them in operator logs is part of the security posture. + +**Editor note on future additions.** The wildcard `webhook_*` terminal-failure classification above is an eager sweep: any new code added to the taxonomy inherits terminal-per-delivery semantics without individual review. Editors adding a new `webhook_*` code that SHOULD be retryable (e.g., a future transient-infrastructure signal) MUST update this paragraph to carve out the exception at the point of addition — do not rely on the pattern match to remain safe for codes not yet defined. + +##### Webhook migration timeline + +| Phase | Behavior | +|---|---| +| 3.0 GA | 9421 webhook signing is baseline for any seller that emits webhooks. Legacy HMAC-SHA256 fallback available when buyer populates `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`; sellers MAY decline to support it. | +| 3.x | HMAC fallback is deprecated. Sellers SHOULD log warnings when selected. SDKs SHOULD surface a deprecation notice to buyers that still configure `authentication`. Condition (b) of `request_signature_required` is deprecated, with removal in 4.0 alongside the block. | +| 3.3 | The RFC 9421 webhook profile (`adcp/webhook-signing/v1`) is deprecated and stays fully functional through 3.x. The experimental Web Bot Auth profile signs a webhook as an ordinary request from the seller's origin. | +| 4.0 | `authentication` on `push_notification_config` and `accounts[].notification_configs[]` is removed from the schema. The RFC 9421 webhook profile is removed; webhooks are signed under the Web Bot Auth profile. | + +#### 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, six signing systems in 3.2, each `kid`-scoped: + +- governance JWS — `adcp_use: "governance-signing"` +- request signing (RFC 9421) — `adcp_use: "request-signing"` (also signs webhooks; see [Webhook callbacks](#webhook-callbacks)) +- webhook signing (RFC 9421) — uses the `request-signing` key; the legacy `adcp_use: "webhook-signing"` value is **deprecated** (still accepted, pending removal — see follow-up issue in the deprecation note) +- designated-task response-payload JWS — `adcp_use: "response-signing"` (see [Designated-task payload-envelope response signing](#designated-task-response-signing) above) +- portable credentials issued by an AdCP agent — `adcp_use: "attestation-signing"` (the credential proof format still defines the signature input and algorithm) +- 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. [Agent resolution](#agent-resolution) does not apply to TMP; the TMP specification defines its key source. + +#### 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.3.0-beta.3/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.0–3.1 | Reference SDKs ship; pilots surface bugs | `covers_content_digest` defaults to `"either"`; explicit `"forbidden"` remains available for legacy body-rewriting infrastructure. Early adopters turn on `required_for` incrementally. | +| 3.2 | Signing remains optional; body integrity becomes mandatory when used | An agent advertising request signing MUST explicitly emit `covers_content_digest: "required"`. Every accepted signature on a request with a body covers `content-digest`; legacy modes require a separately configured 3.0/3.1 endpoint. | +| 3.3 | Web Bot Auth profile added (experimental); RFC 9421 request and webhook profiles deprecated | Signers MAY sign with `web-bot-auth` (origin identity, key directory at the origin, no capabilities call). The RFC 9421 profiles (`adcp/request-signing/v1`, `adcp/webhook-signing/v1`) stay fully functional through 3.x. Verifiers that advertise both apply the coexistence rules in [Web Bot Auth identities](#web-bot-auth-identities). | +| 4.0 | Required for spend-committing operations | `required_for` MUST include `create_media_buy`, `acquire_*`, and every spend-committing operation the verifier supports. Signers MUST sign; legacy digest modes are removed. The RFC 9421 request and webhook profiles are removed, so signatures use the Web Bot Auth profile. | + +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 except for the two domain-derived well-known bootstrap URLs. The initial `/.well-known/brand.json` fetch permits only the original hostname ↔ exact `www` counterpart, HTTPS on the original port, ≤ 3 hops. The initial `/.well-known/adagents.json` fetch permits only hosts in the original eTLD+1, HTTPS-preserving, ≤ 3 hops. Both are anchored on the original request and re-apply SSRF validation per hop. Explicit URLs — including either file's `authoritative_location`, `identity.brand_json_url`, JWKS, revocation lists, and webhook callbacks — use zero redirects. JSON `authoritative_location` and `house` variants are document-level indirection and do not expand the HTTP redirect budget. +- 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 `get_adcp_capabilities` MAY use 0-RTT; everything else MUST NOT. + +### mTLS transport + +When [mTLS](/dist/docs/3.3.0-beta.3/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}'` +// ); +``` + +### Response Size Bounds + +An agent response is untrusted input. A client that reads whatever a +counterparty chooses to send has handed that counterparty an allocation +primitive: a single discovery reply of a few tens of megabytes can drive a +buyer process to multi-gigabyte peak usage once the bytes are decompressed, +parsed into JSON, and materialized as objects — most of it outside the +runtime's own managed heap, where heap-based limits do not see it. The process +dies before schema validation, prompt-injection hygiene, or any other control +on this page gets to run. Checking the size after the body is buffered is too +late; the allocation already happened. + +Clients **SHOULD** bound the size of every AdCP response they read on their own +call path. That covers every MCP response the client reads — `tools/call`, +`tools/list`, `initialize`, and the transport-native `tasks/get` and +`tasks/result` results that carry an async `CallToolResult` — every A2A message +and task operation including streamed frames, the A2A agent card at +`.well-known/agent-card.json` and its authenticated extended form, and the +`get_adcp_capabilities` response. A bound that satisfies this recommendation +has all of the following properties. + +1. **It is enforced while the body is received.** Bytes are counted as they + arrive and the connection is aborted as soon as the count exceeds the + limit. A check performed after the body is buffered, decoded, or parsed is + not such a bound. In practice this rules out the HTTP client's + buffered-body convenience API — `requests.get()` without `stream=True`, + `HttpResponse.BodyHandlers.ofString()`, `io.ReadAll(resp.Body)`, + `await res.json()` — all of which materialize the whole body before any + caller code can count a byte. +2. **It pre-rejects a declared size.** A `Content-Length` already above the + limit is rejected before the body is read, and the streaming count is still + enforced when that header is absent, chunked, or understated. A response + that carries both `Content-Length` and `Transfer-Encoding: chunked`, or + conflicting duplicate `Content-Length` headers, is rejected outright rather + than reconciled. +3. **It is paired with a deadline.** A connect timeout, a read-idle timeout, + and a total deadline that covers reading the whole body. A peer that drips + bytes below the cap forever satisfies a byte limit and still holds a + connection, a partial buffer, and a worker slot; only the deadline bounds + it. This is the same pairing the [brand.json trust-root fetch](#discovering-an-agents-signing-keys-via-brand_json_url) + already requires (step 4) and the [SSRF fetch rules](#webhook-url-validation-ssrf) + recommend (step 5). +4. **It counts raw and decoded bytes, and bounds the decoder.** The limit + applies to the bytes received *and* to the bytes after content decoding, + whichever trips first, and the decompressor's output buffer is bounded by + the remaining allowance so no single decode step can emit more than the + limit. Counting only decoded bytes leaves two holes: a compressed stream + that decodes to almost nothing never trips the counter and runs forever, + and a decompress-then-measure implementation materializes the zip bomb + before it measures it. Clients SHOULD additionally send + `Accept-Encoding: identity`. +5. **It covers streamed responses per event and per call.** Every byte + received on the stream counts — event data, field names, `id:` and `retry:` + lines, comment and keepalive lines, and inter-event whitespace — enforced + on bytes accumulated rather than on events dispatched, so neither an + endless keepalive run nor one unterminated `data:` line escapes it. The + per-call bound is the total decoded bytes the client **retains** for one + task; it carries across stream reconnects, A2A `SubscribeToTask` + resumption, and MCP `Last-Event-ID` resumption. A client that resumes a + stream resumes its count rather than resetting it. Frames the client + consumes and discards — interim `statusUpdate` progress snapshots, which + are not accumulated — count only against the per-event bound. Without the + cumulative bound a peer escapes the limit by answering with + `Content-Type: text/event-stream` and paying out the same payload across + many small frames. +6. **It bounds what the bytes become.** A byte cap bounds ingress; it does not + bound memory. 4 MiB of densely nested arrays is on the order of two million + JSON nodes, which a JavaScript or JVM runtime materializes at tens of bytes + each, and deeply nested input exhausts the stack in a recursive-descent + parser or a generated validator before any schema check runs. After the + byte cap passes, clients enforce a maximum JSON nesting depth of 64 and at + most 100,000 aggregate object members and array elements while parsing, + matching the task-body caps in [Attestation resolution fetch contract](#attestation-resolution-fetch-contract), + and reject an over-limit document before schema validation, logging of body + content, or any LLM-visible processing. These bounds, not the byte cap, are + what makes a boundary-probing peer's cap-minus-one response harmless. They + do not bound the length of a single scalar: a conforming document can carry + one string as large as the whole cap in a single node. Per-field length + limits, not the node count, are what bound what reaches a log or a model + context window. +7. **It errors; it does not truncate.** An oversized response is a failure. + A truncated response is a schema-invalid response that may still parse, so + silently dropping the tail yields a plausible-looking partial answer the + caller cannot distinguish from a complete one — a correctness bug laid on + top of the availability one. SDKs SHOULD raise a distinct, documented error + type (`ResponseTooLarge` or the equivalent naming in the host language). + This is a client-local error class, not an `adcp_error.code`: it never + appears on the wire and is not registered in the + [error-code enum](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling). An + orchestrator relaying the failure upstream maps it to a registered code + with `recovery: "permanent"`. Clients MUST NOT automatically retry the same + request — the peer will send the same oversized body, so a transient-class + retry hands the peer three or four times the amplification for free. A + retry is meaningful only after the caller narrows the request to a smaller + page or fewer fields. The error carries the observed byte count, the + configured limit, and the agent identity; it MUST NOT carry a prefix, + sample, or excerpt of the oversized body, which has passed none of the + string hygiene below, and MUST NOT be placed in an LLM context window or + forwarded verbatim to another agent. +8. **It defaults to no more than 4 MiB (4,194,304 bytes).** The limit SHOULD + be configurable per agent and per call, so a buyer with a specific reason to + accept a larger response from a known counterparty raises it deliberately + rather than globally. A client that implements this bound MUST NOT ship an + unbounded mode as the default. An unbounded mode, if offered at all, is a + separately named unsafe switch rather than a numeric limit set to zero, + null, or infinity; it is not settable per call, where options are often + assembled next to LLM-influenced parameters; and each call made under it is + logged. +9. **It bounds aggregate exposure, not just one response.** A client that fans + out across agents bounds concurrent in-flight response reads and the total + bytes *and nodes* retained across a batch. N peers each answering one byte + under the cap are all individually conformant and collectively fatal to a + worker — which is the shape of the deployment this control exists to + protect. The per-call default is not multiplied by concurrency: a client + whose aggregate budget is smaller than its concurrency times the per-call + default lowers one of the two, and the aggregate bound is the one that must + hold. Node budgets aggregate the same way — N peers each at the 100,000-member + bound is N × 100,000 nodes resident, which is the term that actually + exhausts a fan-out worker. The + [brand.json trust-root fetch](#discovering-an-agents-signing-keys-via-brand_json_url) + bounds concurrent fetches for the same reason. + +Redirect hops on a response path count cumulatively toward the same limit and +the hop count itself is bounded; the response header section is bounded +separately from the body, since HTTP clients ship generous header defaults +(Go's `MaxResponseHeaderBytes` defaults to 10 MB) that a peer can spend before +the first body byte. + +**Precedence.** 4 MiB is a ceiling on what a *default* may be, not a target and +not a license to raise a tighter path-specific cap. Where a path defines its +own cap, that cap governs that path: `MAX_BRAND_JSON_BYTES` (2 MiB), +`MAX_JWKS_BYTES` (64 KiB), `MAX_CAPABILITIES_BYTES` (64 KiB, on the capabilities +fetch of the deprecated RFC 9421 key-discovery chain only), the 4 MiB +attestation task-body cap, the 4096-byte `adcp_error` cap in +[Transport Error Mapping](/dist/docs/3.3.0-beta.3/building/operating/transport-errors#security-considerations), +the 5 MB general SSRF body cap for fetches of counterparty-supplied URLs +(20 MB for the managed-network dereferenced authoritative file), the 1 MiB +streamed cap on an [attestation resolution fetch](#attestation-resolution-fetch-contract) +and the 1 MiB protocol ceiling on `max_embedded_credential_bytes`, the 1 MiB +publisher-origin cap on `format_schema` and `adagents.json` fetches, and the +256 KB A2A auth-challenge fetch cap. Aligning the transport defaults raises +none of these. Both transport profiles default to 4 MiB — a serialized MCP +message (see +[MCP response extraction](/dist/docs/3.3.0-beta.3/building/by-layer/L0/mcp-response-extraction#size-limits)) +and an A2A call (see [A2A response extraction](/dist/docs/3.3.0-beta.3/building/by-layer/L0/a2a-response-extraction#size-limits)) +— against a 128 KiB producer interoperability target for a pageable read (see +[Managing response size](/dist/docs/3.3.0-beta.3/building/concepts/managing-response-size#interoperability-size-target)). +The gap between those two numbers is deliberate: 128 KiB is what a cooperating +producer aims at, and 4 MiB is what a client tolerates from one that is hostile +or broken. A tighter default is always conformant; a larger one is not. The +bound in this section is the backstop for a path with no cap of its own. + +**Producers paginate.** A producer whose data does not fit +[paginates](/dist/docs/3.3.0-beta.3/building/concepts/managing-response-size#pagination-for-cardinality-control); +it does not ask buyers to raise their limits. A response that only succeeds +against a raised limit is not interoperable, and it fails against a +default-configured buyer as a mid-body connection abort rather than as a +protocol error the buyer can act on. + +**Discovery responses stay small.** The client cap is a backstop, not a budget +to spend. Agents **SHOULD** keep their discovery responses — `tools/list`, +`get_adcp_capabilities`, and the A2A agent card — well under the default +client cap, and **SHOULD** paginate a large catalog rather than serve it in +one response. These are the first bytes any counterparty reads, and the +recourse when a cap trips differs by surface. A buyer that trips its cap on +the agent card has none: the fetch is a plain `GET` with no narrowing lever, +and the authenticated extended card is larger, not smaller. On `tools/list` +the page size is the server's choice — MCP's `cursor` carries no page-size +parameter — so a buyer cannot ask for less, though AdCP task names are fixed +by the protocol, so it can still call the named tasks directly. A buyer can +narrow `get_adcp_capabilities` with `protocols[]`, but that does not help on +the deprecated RFC 9421 signing-key discovery path. That path fetches +capabilities unnarrowed under `MAX_CAPABILITIES_BYTES` (64 KiB), so a +capabilities document above that budget fails with +`request_signature_capabilities_unreachable` and makes the agent's signing keys +unresolvable for any verifier without a live cached mapping, and for the rest +within one cache cycle. For an agent that signs under that profile, an +oversized capabilities document therefore costs signature verification as well +as discovery. See +[Discovering an agent's signing keys](#discovering-an-agents-signing-keys-via-brand_json_url) +and [Discovery responses stay small](/dist/docs/3.3.0-beta.3/building/concepts/managing-response-size#discovery-responses-stay-small). + +**Counterparty-asserted fields are bounded separately.** Not every byte of a +discovery response is something the agent decided to say. Where a field +enumerates relationships a *counterparty* asserts, a third party controls its +contents: `media_buy.portfolio.publisher_domains` and +`signals.data_provider_domains` are both populated from the counterparty's own +`/.well-known/adagents.json`, which any domain owner publishes unilaterally and +without the named agent's consent. An agent that mirrors "every publisher whose +adagents.json names me" has handed an unauthenticated stranger a write +primitive into its own capabilities document — and because the deprecated +RFC 9421 [signing-key discovery path](#discovering-an-agents-signing-keys-via-brand_json_url) +fetches that document unnarrowed under `MAX_CAPABILITIES_BYTES`, inflating it +past 64 KiB costs a victim that signs under that profile signature +verification, not merely discovery. The attacker needs no relationship with +the victim, and the failure +arrives a cache cycle later, far from its cause. + +Agents therefore **MUST NOT** reflect a counterparty-asserted list unbounded. +A single counterparty-asserted field **MUST NOT** exceed 8,192 bytes, and all +such fields together **MUST NOT** exceed 16,384 bytes. Measure uncompressed +serialized JSON — the member name, the array punctuation, and the quoted +elements — because that is the unit the consumer's cap is enforced in: a +field that fits only once gzipped still trips a verifier counting decoded +bytes. Today exactly two fields carry this class, so the aggregate equals +their sum and binds nothing the per-field rule does not; it is written to +hold when a third such field lands. + +**These are sub-caps, not an allowance.** They cap what a third party can +write into the document. They are not an entitlement for self-declared fields +to grow into the rest. An agent that signs under the deprecated RFC 9421 +profile, whose counterparty-asserted fields sit at 2 KiB and whose format +catalog takes the document past that profile's 64 KiB fetch budget, has +satisfied every MUST in this paragraph and still lost signature verification — +self-inflicted rather than attacker-inflicted, and just as broken. + +**Capabilities are bounded by structure.** From 3.3 the +`get_adcp_capabilities` response schema, and every shared schema it references, +bounds each array with a maximum item count, each string with a maximum +length, and each keyed map with a maximum member count, so that the largest +schema-valid document can be computed instead of discovered when a fetch fails. +Three kinds of position need no keyword, because the schema already bounds +them, and the calculation prices each from the schema: + +- a string constrained by `enum`, `const`, or `format` (a `format` is priced at + its ceiling, `uri` at 2,048 characters for example); +- a string with an anchored `pattern` that admits only values of bounded + length, such as `^[A-Z]{2}$` or `^sha256:[a-f0-9]{64}$`. An anchored pattern + with an unbounded quantifier, such as `^[a-z][a-z0-9_]*$`, does not bound + length and still carries `maxLength`; +- an array with `uniqueItems` whose items are a closed `enum` or a `const`, + whose length cannot exceed the number of values. + +The limits on every other position are sized to a real deployment with +headroom, in classes: + +| Class | Limit | Applies to | +|---|---|---| +| Identifier or token | 64 to 128 characters | snake-case tokens, ids, profile tokens, labels and names (128), versions (32) | +| Fixed-shape string | exact length of the pattern | ISO country (2), ISO subdivision (6), hex color (7), time of day (8), SHA-256 hex (64), digest (71) | +| Domain | 253 characters | every domain-valued field | +| Short free text | 256 to 1,024 characters | descriptions, messages, suggestions, alt text (512), pointers (256) | +| Vocabulary list | at least twice the enum, at least 16 | arrays of enum values that are not `uniqueItems` | +| Token or URI list | 16 to 32 items | claim types, proof formats, languages, industries | +| Small object list | 4 to 32 items | SDK entries, onboarding requirements, jurisdictions, window constraints, macro capabilities | +| Catalog | 128 to 256 items | `creative.supported_formats` (256), `measurement.metrics` and vendor metrics (128), governance features (128), preview routes (128) | +| Offerings and issuers | 16 to 64 items | reporting offerings (64), accepted attestation issuers (32), verifiers (16) | +| Geography | 8 to 1,024 items | countries (256), subdivisions per country (512), IANA timezones (1,024), place systems (8) | +| Diagnostics | 10 to 20 items | `errors[]` (20), `issues[]` (10) | +| Keyed map | 8 to 256 members | by the key vocabulary: ISO country keys (256), billing parties (8) | + +Where a field is new in 3.3 or experimental, the bound is an ordinary schema +keyword (`maxItems`, `maxLength`, `maxProperties`) and the schema enforces it. +A minor release may not tighten a field that was stable before it, so on those +fields the schema enforces nothing new in 3.x. Each carries the bound as an +annotation instead, `x-max-items-4.0`, `x-max-length-4.0`, or +`x-max-properties-4.0`, which becomes the keyword in 4.0. Through 3.x a producer +**SHOULD** stay within the annotated limit, and a document above it stays +schema-valid. The annotations are registered as a 4.0 tightening in the +[4.0 compatibility checks](/dist/docs/3.3.0-beta.3/reference/migration/v4-compatibility#capabilities-stay-within-their-structural-bounds). + +**Worst case.** With the 4.0 bounds applied, the largest schema-valid +capabilities document is 91,816,255 bytes (about 88 MiB), counting only the +members the schema declares. `scripts/capabilities-structural-bounds.cjs` computes it +and `tests/capabilities-structural-bounds.test.cjs` pins it, so the figure moves +only when a limit moves. The calculation assumes every declared member is +present, every list and map is full, every string is at its maximum length, a +union takes its widest branch, free text takes four bytes per character, and +patterned or formatted strings one. The largest blocks are `creative` (27 MiB), +`media_buy` (27 MiB), `adcp` (12 MiB), `governance` (6 MiB), `account` (5 MiB), +and `measurement` (4 MiB). Objects that admit undeclared members, such as `ext` +and context, are open: the schema cannot bound what a producer adds there, and +107 positions in the response are open in this sense. + +The multipliers behind that figure are nested object lists inside shared +schemas: a brand reference (`brand-ref`, up to 111 KiB each, most of it +`brand_kit_override` and `provenance`), a creative format declaration +(`creative-operation-format-declaration`, up to 107 KiB each, most of it +`macro_resolution_capabilities`), an attestation issuer entry (322 KiB each), +an audience activation method (144 KiB), and a geographic place system (534 +KiB). The way to lower that ceiling is a tighter structural bound on those +nested lists, not a byte cap. +Tightening those nested limits is registered as a 4.0 follow-up in the +[4.0 compatibility checks](/dist/docs/3.3.0-beta.3/reference/migration/v4-compatibility#capabilities-stay-within-their-structural-bounds). + +**The structural bounds are the limit.** The structural bounds are the +protocol's limit on the `get_adcp_capabilities` response. Agents **MUST NOT** +design to a byte figure. The worst-case figure describes the shape the bounds +allow; it is not a size target. The bounds cover the members the schema +declares; members a producer adds at the open positions above, such as `ext`, +fall outside them. + +**The byte figure is a receiver backstop.** Receivers commonly bound response +size at the HTTP or gateway layer, as they do for any body they read. A limit +around 64 KiB (`MAX_CAPABILITIES_BYTES`) is a reasonable backstop against +runaway or hostile documents. It is not a design target. The figure is +deployment guidance for receivers, not a protocol rule on the response. Its +only job is to keep such a document from exhausting a verifier. The same +constant is also the fetch budget of step 1 in the deprecated +[RFC 9421 discovery chain](#discovering-an-agents-signing-keys-via-brand_json_url). +That budget is unchanged through 3.x, and 4.0 removes it with the chain. + +Nothing large belongs in capabilities. Outside the deprecated chain, keys come +from the signer's key directory, and no new path reads keys or a key pointer +from this response. Property lists travel as references that +the receiver resolves with `get_property_list`, and publisher authorization +lives in each publisher's `adagents.json`. Structural bounds make growth +predictable and give a producer a per-field budget to design to. They do not +make a document small, so a producer still keeps it small, as **Discovery +responses stay small** earlier in this section says. + +The structural bounds carried in `x-adcp-validation` — at most 100 unique +entries, each at most 253 characters — are a coarse ceiling and are not +sufficient on their own: a count bound cannot express bytes, a hundred-entry +list of maximum-length domains is thirteen times the size of a hundred +ordinary ones, and two fields at those limits are already most of the budget. +Through 3.x they are graded by the conformance storyboard rather than enforced +by the schema, so a document already above them is reported, not rejected; +they become schema constraints in 4.0. The byte budget above is the bound that +binds in 3.x. Where the real set does not fit, an agent +**MUST** truncate and mark the list incomplete +(`publisher_domains_complete: false`, `data_provider_domains_complete: false`) +rather than exceed the budget, and **MUST NOT** drop a field it does in fact +populate or serve a truncated list as complete. Omitting a field that has no +members is unaffected. Consumers **MUST NOT** treat either list as an +authorization allowlist, whether or not it is marked complete — the +counterparty's own `adagents.json` is the authority, and absence from the +list is not a denial. + +A schema bound is a conformance statement, not a runtime control: it makes an +inflated document non-conformant, it does not make it unservable. The control +is the producing agent bounding what it mirrors. + +**Scope.** This section covers responses a client reads on its own call path. +Inbound deliveries a buyer *receives* — A2A push notifications and +[AdCP webhooks](#webhook-callbacks) — are a server-side surface with its +own request-body cap; see [Inbound body bounds](#inbound-body-bounds). The same +streaming discipline applies there. + +These bounds are the discipline the [SSRF fetch rules](#webhook-url-validation-ssrf) +already apply to counterparty-supplied URLs and the +[brand.json trust-root fetch](#discovering-an-agents-signing-keys-via-brand_json_url) +applies to `MAX_BRAND_JSON_BYTES`, extended to the responses an agent returns +on its own call path. They do not replace the per-field hygiene in +[Transport Error Mapping](/dist/docs/3.3.0-beta.3/building/operating/transport-errors#security-considerations) +— length limits on seller strings, control-character stripping, +prototype-pollution filtering. A size bound is what keeps the process alive +long enough to apply them. + +### Inbound body bounds + +A webhook endpoint is reachable by anyone who learns its URL, and its request +body is untrusted until signature verification passes. The memory-exhaustion +shape from [Response Size Bounds](#response-size-bounds) applies unchanged: the +peer chooses the byte count, and the expensive work — decompression, JSON +parsing, RFC 9421 or Web Bot Auth (WBA) verification — runs before any trust +decision. An unauthenticated peer that can force that path has an allocation +primitive. + +Receivers of A2A push notifications (`push_notification_config.url` +deliveries) and of AdCP webhooks — reporting, artifact, collection-list, and +account notification subscribers — **SHOULD** bound the request body. The bound +is the same discipline as the client-side one, applied to what a peer pushes +rather than what a client pulls: + +1. **It pre-rejects on `Content-Length`.** A declared length over the cap is + rejected before the first body byte is read. A request with no + `Content-Length`, such as chunked transfer, is not exempt; the streaming + count below is what bounds it. +2. **It counts decoded bytes while streaming.** The count covers bytes after + any `Content-Encoding` is decoded, and the decoder output buffer is bounded, + so a small compressed body cannot expand past the cap. Raw bytes off the wire + are counted against the same cap as well, whichever trips first, so a chunked + body with no `Content-Length` cannot run unbounded. The receiver aborts the + read the moment a count passes the cap rather than buffering the body and + measuring it afterwards. A receiver MAY reject any `Content-Encoding` other + than identity on a webhook route; nothing in the webhook profile requires a + compressed body. A request carrying both `Content-Length` and + `Transfer-Encoding: chunked`, or conflicting `Content-Length` values, is + rejected rather than reconciled, as on the response side. +3. **It bounds what the bytes become, before verification.** After the byte + cap passes, the receiver enforces a maximum JSON nesting depth of 64 and at + most 100,000 aggregate object members and array elements, matching the + task-body caps in [Attestation resolution fetch contract](#attestation-resolution-fetch-contract), + and rejects an over-limit document **before** RFC 9421 or WBA verification + (and before any legacy HMAC or Bearer check). The bounds are enforced + incrementally, by a streaming or abort-on-limit parser, not by buffering the + body and running a full parse that has already allocated the document: a + counting scanner that aborts at depth 65 or member 100,001 does not itself + become the allocation primitive. Verification parses and canonicalizes the + request — signature parameters, a `content-digest` or HMAC computed over the + full body, and, for webhooks, the body-well-formedness check that is the + only step to parse the body itself — and a legacy or receiver-specific path + may parse the body earlier still. Bounding first keeps every one of those + paths away from input an unauthenticated peer chose. The order is: byte cap, + then depth and member bounds, then verification. +4. **It applies deadlines.** A connect deadline (for a server, the connection + and TLS handshake together with the request-header read), a read-idle + deadline, and a total deadline for the whole request. The request header + section is bounded separately from the body. The spec fixes no values here; + the numeric budgets on the [brand.json fetch](#discovering-an-agents-signing-keys-via-brand_json_url) + are a reasonable reference. A peer that opens a connection and + trickles bytes, or stalls mid-body, otherwise holds a worker for as long as + it likes without ever tripping a byte cap. +5. **It errors; it does not truncate.** An over-limit request is rejected with + HTTP `413 Content Too Large`, for a byte, depth, or member violation alike. + A truncated body is a payload the receiver + might still parse, and the signature and `content-digest` would not cover + it. The rejection is an HTTP status, not an `adcp_error.code` and not a + `webhook_*` verification code, because it happens before verification + runs. The receiver MUST NOT log, echo, or process any excerpt of the + rejected body, which has passed none of the verification or string hygiene + checks. The same body fails the same way on every attempt, so a sender that + retries a `413` on its non-2xx backoff schedule (see + [retry semantics](#webhook-callbacks)) only repeats the cost. The receiver + closes the connection when it has not read the full body. + +**Default.** The cap defaults to no more than 4 MiB (4,194,304 bytes). This +generalizes the 4 MiB task-body cap in +[Attestation resolution fetch contract](#attestation-resolution-fetch-contract) +to inbound push and webhook deliveries rather than adding a second number, and the depth and +member bounds are that section's 64 and 100,000. The limit SHOULD be +configurable per endpoint, so a receiver with a specific reason to accept a +larger body from a known sender raises it deliberately rather than globally. A +receiver that implements this bound MUST NOT ship an unbounded mode as the +default. This section does not weaken the attestation task-body cap, which +remains a MUST wherever a task can carry portable attestations. + +**Precedence.** As in the response-side [Precedence](#response-size-bounds) +paragraph, 4 MiB is a ceiling on what a default may be, not a target and not a +license to raise a tighter cap. Where a feature defines its own inbound cap, +that cap governs that feature's deliveries — for example, the 1 MB payload +limit in [Webhooks](/dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks#security-requirements) +governs webhook deliveries. A tighter default is always +conformant; a larger one is not. + +## 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 +- [ ] Bound every agent response, enforced while the body streams, defaulting to no more than 4 MiB ([response size bounds](#response-size-bounds)) +- [ ] Bound every inbound push-notification and webhook body, enforced before signature verification, defaulting to no more than 4 MiB ([inbound body bounds](#inbound-body-bounds)) +- [ ] 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.3.0-beta.3/building/concepts/security-model) for the threat model and the five-layer defense narrative this reference implements +- **Webhooks**: See [Webhooks](/dist/docs/3.3.0-beta.3/building/by-layer/L3/webhooks) for webhook security patterns +- **Error Handling**: See [Error Handling](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling) for authentication errors +- **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.3.0-beta.3/building/operating/orchestrator-design) for multi-tenant security diff --git a/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile.mdx b/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile.mdx new file mode 100644 index 00000000000..3befad396de --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/by-layer/L1/wba-profile.mdx @@ -0,0 +1,303 @@ +--- +title: Web Bot Auth request-signing profile +sidebarTitle: Web Bot Auth profile +description: "Experimental AdCP 3.3 Web Bot Auth profile for request and webhook signing: keys from Signature-Agent, an origin as the identity, and one web-bot-auth signature." +"og:title": "AdCP — Web Bot Auth request-signing profile" +--- + +This page defines an AdCP profile of Web Bot Auth (WBA), the IETF protocol for identifying automated HTTP clients. Under the profile, a seller identifies a signing agent from the request itself. The signer names its origin in a covered `Signature-Agent` header, and the seller fetches the signer's keys from that origin's key directory. The seller then decides, under AdCP's own rules, what the identified agent may do. + + +**Status: experimental in AdCP 3.3**, adopted by [DR-0023](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0023-agent-identity-is-an-origin.md). The profile is published alongside the AdCP RFC 9421 profile in [Signed Requests (Transport Layer)](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#signed-requests-transport-layer), which stays as written. Document signing and transparency logs are separate decisions and are not part of this page. + + +The profile selects options that WBA and RFC 9421 already define. It adds no headers, tags, key fields, or error codes to the wire. Any implementation of [draft-ietf-webbotauth-httpsig-protocol-00](https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/00/) can therefore sign and verify AdCP requests once it is configured with the rules on this page. Section numbers in this page refer to that draft unless they name RFC 9421. + +## What the profile changes for a seller + +Under the AdCP RFC 9421 profile, a seller verifies a signature only from an agent it has onboarded. The agent's key location comes from onboarding or from a registry the seller trusts. Under this profile, the seller learns the signer's identity from the first signed request. Onboarding becomes approving a verified identity instead of issuing a credential. The seller approves an identity from a domain allowlist, from a commercial agreement, or with one click in its admin interface. After that, every signed request from the identity is authenticated without a bearer token. Until the seller approves the identity, the identity gets no account access, reads included. See [Onboarding](#onboarding). + +## One resolution rule + +A verifier resolves a signer's keys in one step: + +```text +Signature-Agent member -> origin -> https:///.well-known/http-message-signatures-directory +``` + +The verifier reads the `Signature-Agent` member keyed to the signature label and takes the origin it names. The member value is exactly the ASCII serialization of an `https` origin (RFC 6454 section 6.2), such as `https://buyer-7k3q.com`: a lowercase host, no default port, no trailing `/`, and no path, query, fragment, or userinfo (sections 5.2.1 and 5.5). A signer sends that canonical origin. A verifier compares the value with it as a string, without normalizing, and refuses any other value, including an `http` origin, with `403`. It fetches the HTTP Message Signatures Directory at that origin's well-known path (section 5.5). One fetch returns one file, and the verifier reads no other document to find keys. In particular it never fetches `get_adcp_capabilities` for keys, so the size of an agent's capabilities document cannot make its signing keys unresolvable. The directory is the only key location the profile recognizes. The verifier ignores a `Signature-Agent` member with a `type` parameter other than `directory`, as section 5.2.1 permits for a type the verifier does not support. Section 5.2.1 defines `type` as a Token, and a verifier accepts `type=directory` as a Token or as a String, as the reference library does. + +Section 5.5 explains why the well-known path matters: "TLS authenticates the host but not the path", and "The well-known URI is reserved, so directory additionally names a domain". + +## Identity + +Under the profile, an identity is an origin, for example `https://buyer-7k3q.com`. The identity has one key directory, and every key it signs with is published there. The verifier attributes a verified request to that origin and to nothing else. + +Two agents cannot share an origin. An operator that runs several agents gives each its own origin, for example one subdomain per agent. Section 6.5 recommends this for agents that need to be told apart: "these should use distinct signing keys and signing key directories". The same rule covers purposes: a governance agent is an agent like any other, on its own origin (see [One agent per purpose](#one-agent-per-purpose)). + +`brand.json`, `adagents.json`, and a seller's onboarding records refer to an identity by its origin. So does [`trust.json`](/dist/docs/3.3.0-beta.3/reference/trust-json): it lists agent origins with their roles and carries no keys. A multi-tenant vendor gives each tenant its own subdomain, for example `https://7f3a9c.sentinel-governance.example`, which serves that tenant's key directory. + +An agent that shares a host with other agents under the AdCP RFC 9421 profile keeps using that profile until it moves to its own origin. When it moves, the seller maps the agent's principal to that origin explicitly. Key discovery therefore never changes who the seller considers the caller to be. + +Key lookup is keyed on the pair of origin and key, never on the key alone (section 5.4). A verifier that indexes keys by thumbprint alone attributes a request to whatever origin the signer claims. Negative vector `004-key-not-in-named-directory` grades this rule. + +## One agent per purpose + +Purpose belongs to the agent, not to the key. A governance agent is an identity like any other, on its own origin, with its own key directory, so no key and no directory states a purpose. JWS artifacts, such as governance tokens, are outside this profile. + +## Profile rules + +RFC 9421 section 3.2.1 lets an application impose requirements beyond the base specification. Its examples include "Requiring a specific set of header fields to be signed", "Enforcing a maximum signature age", and "Enforcing uniqueness of the nonce parameter". The profile consists of the following rules. Each rule is a requirement of that kind or a choice among options WBA defines. + +| Rule | Profile choice | Basis | +|---|---|---| +| Signature | RFC 9421 with `tag="web-bot-auth"`, `keyid` set to the key's JWK thumbprint, and a covered `Signature-Agent` member whose value is the ASCII serialization of an `https` origin (RFC 6454 section 6.2), with no path, query, fragment, or userinfo | Sections 5.2, 5.2.1, 5.5 | +| `Signature-Agent` value | Exactly the ASCII serialization of an `https` origin: a lowercase host, no default port, no trailing `/`, and no path, query, fragment, or userinfo. A verifier compares the string without normalizing and refuses any other value with `403` | Sections 5.2.1 and 5.5; RFC 6454 section 6.2 | +| Components, every request | `@method`, `@target-uri`, `@authority`, `"signature-agent";key= + +#### Which hold path does your seller use? + +Both `sales-guaranteed` and `sales-fixed-rate` can involve a human decision before the seller commits. They surface it differently on the wire: + +| | `sales-guaranteed` | `sales-fixed-rate` | +|---|---|---| +| What the seller commits to | Delivery against an IO | A published price; no delivery commitment | +| Hold mechanism | Submitted task arm: [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy) returns `status: "submitted"` with a `task_id` | Synchronous success: `create_media_buy` returns a `media_buy_id` with `confirmed_at: null` | +| `media_buy_id` exists while held | No. It is issued when the task completes (for example, when the IO is signed). | Yes, immediately. The buy is retrievable through [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys). | +| How the buyer observes the outcome | Poll the task or receive the task webhook | Poll `get_media_buys` and watch `confirmed_at`, `status`, and `revision` | +| Grading | Required for `sales-guaranteed` | Optional hold phase, graded only when the seller advertises the `force_media_buy_confirmation` controller scenario | + +Commitment is keyed off `confirmed_at`, never off `status`. A buy with `confirmed_at: null` is not committed, whatever its `status` says, and it is never `active`. + + + + + +`media_buy.supports_proposals` is a boolean in the `media_buy` capabilities block of your `get_adcp_capabilities` response. It gates whether the `proposal_finalize` compliance scenario runs. It is a conformance declaration, not the buyer's per-proposal routing signal: buyers decide whether a returned proposal can be bought from `proposal_status`. + +| If you… | Set | +|---|---| +| Accept RFPs, generate proposals, and finalize draft proposals to committed status before create | `media_buy.supports_proposals: true` | +| Sell direct-buy guaranteed only (auction PG, retail SKU, quoted-rate — no RFP flow) | `media_buy.supports_proposals: false` (or omit — default is `false`) | + +```jsonc +// 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 through [`get_signals`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals); activation is not required for this claim | +| `signal-marketplace` | stable | Marketplace signal agent reselling third-party data; requires `get_signals` and [`activate_signal`](/dist/docs/3.3.0-beta.3/signals/tasks/activate_signal) | + +### governance + +| Specialism | Status | Purpose | +|-----------|--------|---------| +| `content-standards` | stable | Content standards enforcement (brand safety, policy compliance) | +| `property-lists` | stable | Property list governance — curated inclusion and exclusion lists for targeting and delivery compliance | +| `collection-lists` | stable | Collection list governance — curated inclusion and exclusion lists of content programs (shows, series, podcasts) for program-level brand safety | +| `governance-delivery-monitor` | stable | Campaign delivery monitoring with drift detection | +| `governance-spend-authority` | stable | Conditional spend approval and human-in-the-loop governance | + + +**Experimental in 3.1.** Measurement metric-catalog discovery is available through the experimental `measurement` capability block and `measurement.core` experimental feature. A stable `measurement-verification` specialism and baseline storyboard are deferred until the measurement task surface is frozen. + + +### brand + +| Specialism | Status | Purpose | +|-----------|--------|---------| +| `brand-rights` | stable | Brand identity and rights licensing (talent, music, stock media) | + +### sponsored-intelligence + +| Specialism | Status | Purpose | +|-----------|--------|---------| +| `sponsored-intelligence` | preview | Agent claim for SDKs that dispatch on specialism ID. The graded storyboard is the `sponsored-intelligence` protocol baseline; this specialism reserves the wire ID and promotes to `stable` when the SI tools graduate from `x-status: experimental`. | + +## Cross-resource invariants + +In addition to per-step validations, specialisms declare cross-step and cross-resource **invariants** the runner observes across the full storyboard run. These catch state inconsistencies that no single response shape would surface. + +| Invariant | Scope | Specialisms | +|-----------|-------|-------------| +| `status.monotonic` | Single-resource — rejects status transitions observed across steps that aren't on the spec lifecycle graph. | All specialisms with a stateful resource lifecycle. | +| `impairment.coherence` | Cross-resource — verifies that `media_buy.impairments[]` stays in sync with referenced resources. **Forward**: every entry references a currently-offline resource. **Inverse**: any offline resource referenced by a non-terminal buy appears in `impairments[]`. **Health-iff**: on a non-terminal buy, `health == "impaired"` iff `impairments[]` is non-empty (strict iff — stale drift fails). Out of scope: all three rules relax on terminal-status buys (sellers MAY leave `impairments[]` and `health` in whatever state held at the terminal transition); materiality is schema-enforced via `package_ids: minItems: 1`. | `audience-sync`, `creative-ad-server`, `creative-template`, `creative-generative`, `sales-catalog-driven`. Driven by the [`media_buy_seller/dependency_impairment`](/compliance/latest/protocols/media-buy/scenarios/dependency_impairment.yaml) 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.3.0-beta.3/media-buy/media-buys/lifecycle#compliance) for the full `impairment.coherence` contract. + +## How to claim + +Declare your protocols and specialisms in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/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. Grades `preview` specialisms the same way, but leaves them out of AAO Verified badge issuance + + +**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.3.0-beta.3/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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/protocol/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-fixed-rate` | Pricing mechanic, any channel: `fixed_price` options (cpm, cpp, flat_rate, and so on) on `non_guaranteed` products | [`sales_fixed_rate`](/compliance/latest/specialisms/sales-fixed-rate/index.yaml) | — | +| `sales-broadcast-tv` | `channels: ['linear_tv']` | [`sales_broadcast_tv`](/compliance/latest/specialisms/sales-broadcast-tv/index.yaml) | — | +| `sales-dooh` | `channels: ['dooh']` | [`sales_dooh`](/compliance/latest/specialisms/sales-dooh/index.yaml) | — | +| `sales-social` | `channels: ['social']` | [`sales_social`](/compliance/latest/specialisms/sales-social/index.yaml) | — | +| `audience-sync` | [`sync_audiences`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_audiences) tool | [`audience_sync`](/compliance/latest/specialisms/audience-sync/index.yaml) | — | +| `property-lists` | `property_list` tools | [`property_lists`](/compliance/latest/specialisms/property-lists/index.yaml) | — | +| `collection-lists` | `collection_list` tools | [`collection_lists`](/compliance/latest/specialisms/collection-lists/index.yaml) | — | +| `governance-spend-authority` | [`check_governance`](/dist/docs/3.3.0-beta.3/governance/campaign/tasks/check_governance), [`sync_plans`](/dist/docs/3.3.0-beta.3/governance/campaign/tasks/sync_plans) | [`governance_spend_authority`](/compliance/latest/specialisms/governance-spend-authority/index.yaml) | [`governance_spend_authority/denied`](/compliance/latest/specialisms/governance-spend-authority/denied.yaml) | +| `creative-generative` | [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative) | [`creative_generative`](/compliance/latest/specialisms/creative-generative/index.yaml) | [`creative_generative/seller`](/compliance/latest/specialisms/creative-generative/generative-seller.yaml) | +| `brand-rights` | [`get_brand_identity`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/get_brand_identity), [`acquire_rights`](/dist/docs/3.3.0-beta.3/brand-protocol/tasks/acquire_rights) | [`brand_rights`](/compliance/latest/specialisms/brand-rights/index.yaml) | [`brand_rights/governance_denied`](/compliance/latest/specialisms/brand-rights/scenarios/governance_denied.yaml) | + +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`](/dist/docs/3.3.0-beta.3/governance/property/tasks/property_lists#create_property_list), [`validate_property_delivery`](/dist/docs/3.3.0-beta.3/governance/property/tasks/validate_property_delivery)) and storyboard category `property_lists`. +- `sales-broadcast-tv` declares `channels: ['linear_tv']` — "Broadcast TV" is the prose name; `linear_tv` is the wire value. + +`/compliance/{version}/index.json` surfaces each specialism's `required_tools` so agents can discover the tool families without reading the full storyboard YAML. + +### Wire enum vs prose + +Wire enum values are always `snake_case` (`non_guaranteed`, `pmax_platform`, `ctv`). Prose renders the same concept with hyphens or spaces ("non-guaranteed auction inventory", "Connected TV"). When populating a payload, always use the wire form — hyphenated or spaced spellings are editorial only and will fail schema validation. + +### `signal_type` values + +The `signal_type` enum in signal responses has three values: + +- `marketplace` — the signal agent is reselling segments published by a third-party data provider (Experian, Peer39, etc.). Buyers can verify authorization via the provider's `/.well-known/adagents.json`. +- `owned` — the signal agent exposes its own first-party segments derived from directly owned data (retailer purchase data, publisher behavioral data, telco location data). +- `custom` — the signal source builds the segment on demand from models, composites, or buyer-supplied inputs. Use this when no `adagents.json` authorization chain applies — the segment is source-native, not attributable to a standing upstream provider. + +## Source of truth + +The machine index is published alongside schemas: + +| Path | Contents | +|------|----------| +| `/compliance/{version}/index.json` | Enumerated protocols + specialisms + universal storyboards + per-specialism `status` | +| `/schemas/{version}/enums/specialism.json` | Specialism enum used by `get_adcp_capabilities.specialisms` | +| `/schemas/{version}/enums/adcp-protocol.json` | Task-classification enum referenced by `tasks-list-request` and webhook payloads. Same axis as `supported_protocols` (kebab-case here, snake_case on the wire). | + +The build pipeline verifies the specialism filesystem ↔ enum parity and that every specialism's parent protocol exists in the compliance tree. Drift fails the build. + + +The catalog on this page is maintained by hand to give human context. The authoritative enumeration is always `/compliance/{version}/index.json`. + + + +**Building an agent that wraps an upstream platform?** Storyboards in this catalog grade the AdCP wire contract; they cannot detect adapters that return shape-valid responses without integrating with the upstream. See **[Validate adapter agents with mock upstream fixtures](/dist/docs/3.3.0-beta.3/building/verification/validate-with-mock-fixtures)** for the complementary pre-staging gate. + diff --git a/dist/docs/3.3.0-beta.3/building/verification/conformance.mdx b/dist/docs/3.3.0-beta.3/building/verification/conformance.mdx new file mode 100644 index 00000000000..fc5e70ef8f4 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/verification/conformance.mdx @@ -0,0 +1,313 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/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. | +| Platform catalog with seller-issued or derived identifiers | For storyboards that declare `fixture_resolution`, the runner discovers qualifying entities through the seller's normal AdCP read surface, pins the seller-issued IDs to run-scoped fixture handles, and exercises the normal booking path. Requirements the sandbox catalog cannot satisfy remain visible as `fixture_unsatisfied` coverage gaps. | +| Mixed (some tools local, some upstream) | Both, per tool. | + +All three paths can earn `(Spec)` — each proves the seller's wire behavior against the storyboards, and the run report identifies which fixture strategy supplied the evidence. The bridge is **one implementation** of the test-surface pattern, not a separate seller category. Discovery is not a blanket opt-out from controlled setup: a seller still needs the `force_*` and `simulate_*` scenarios required to prove lifecycle behavior, and an unsatisfied catalog requirement reduces reported coverage rather than passing the test. 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. + +### Fixture handles and catalog resolution + +In AdCP 3.2, fixture IDs are run-scoped handles. Existing storyboards and +sellers retain literal `seed_*` injection by default. A storyboard opts a +fixture into catalog resolution through explicit `fixture_resolution` match +rules; the runner never guesses that an arbitrary fixture object is a generic +subset predicate. + +For product discovery, the runner reads the fully paginated wholesale catalog +for the resolved sandbox account, validates the response normally, selects a +qualifying entity deterministically, and binds the authored handle to the +seller's `product_id`. Product pricing options bind as the scoped pair +`(product_id, pricing_option_id)`. The binding stays fixed for the run and is +substituted only into request fields carrying the corresponding `x-entity` +annotation. + +The grading boundary is important: + +- No qualifying entity after every declared strategy is + `fixture_unsatisfied`: an explicit, reported coverage gap. +- A rejected, malformed, incomplete, or transport-failed discovery call is a + conformance failure. +- Once a supported setup operation begins, its failure is a conformance + failure; the runner cannot fall back to another strategy to conceal it. + +The normative authoring and output contracts live in +[`storyboard-schema.yaml`](https://adcontextprotocol.org/compliance/latest/universal/storyboard-schema) +and +[`runner-output-contract.yaml`](https://adcontextprotocol.org/compliance/latest/universal/runner-output-contract). + +### 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`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/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.3.0-beta.3/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.3.0-beta.3/building/verification/compliance-catalog) for the full taxonomy and [Validate Your Agent](/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent) for how to run the suite locally. + +The non-executable [cross-role governance index](/compliance/latest/universal/governance.yaml) +maps the shared governance contract to capability-gated media-buy, signals, +rights, and creative proofs. It is discoverability metadata, not an additional +universal storyboard; only an agent's exact +`adcp.governance_enforcement.tasks[]` claims select those role-specific tests. + +## 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`](/compliance/latest/universal/capability-discovery.yaml) | [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) shape, protocol/specialism declarations, version advertising | +| [`comply_controller_mode_gate`](/compliance/latest/universal/comply-controller-mode-gate.yaml) | Sandbox/live isolation for sellers that expose [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller) on a shared endpoint — live-mode principals must be refused before scenario dispatch | +| [`schema_validation`](/compliance/latest/universal/schema-validation.yaml) | Request and response schema conformance, ISO 8601 timestamps, temporal invariants | +| [`schema_validation_signals`](/compliance/latest/universal/schema-validation-signals.yaml) | Response schema compliance for signals — required fields on every signal; gated on [`get_signals`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals) | +| [`version_negotiation`](/compliance/latest/universal/version-negotiation.yaml) | Release-precision `adcp.supported_versions` advertisement and response-envelope `adcp_version` echo; advisory in 3.1, promoted in later cuts; a 3.1-pinned scenario for response-shape conformance is advisory pending runner support | +| [`v3_envelope_integrity`](/compliance/latest/universal/v3-envelope-integrity.yaml) | 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`](/compliance/latest/universal/error-compliance.yaml) | Structured error shape, published error codes, transport binding, no existence leaks across tenants | +| [`error_compliance_signals`](/compliance/latest/universal/error-compliance-signals.yaml) | Discovery error codes, context echo and version handling for all signals agents; activation negatives run through the `signal-marketplace` specialism. Full recovery fields and raw transport bindings require separate evidence | +| [`stale_response_advisory`](/compliance/latest/universal/stale-response-advisory.yaml) | [`STALE_RESPONSE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-stale-response) wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | +| [`idempotency`](/compliance/latest/universal/idempotency.yaml) | `idempotency_key` scoping, replay semantics, [`IDEMPOTENCY_CONFLICT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-idempotency-conflict), `replayed: true`, declared TTL | +| [`read_tool_idempotency`](/compliance/latest/universal/read-tool-idempotency.yaml) | Read-only task wrappers and the 3.x [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products) compatibility facade tolerate an optional `idempotency_key` without implying read replay; verifies the keyless capabilities bootstrap | +| [`reporting_core_declaration`](/compliance/latest/universal/reporting-core-declaration.yaml) | The proper-name Reliable Reporting 1.0 declaration and required Core boundary for [`get_reporting_status`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_reporting_status), graded without requiring an optional test controller | +| [`reporting_core`](/compliance/latest/universal/reporting-core.yaml) | Controller-backed Reliable Reporting Core obligation-before-report lifecycle, deadline health, and explicit zero-row reporting | +| [`reporting_consumer_status`](/compliance/latest/universal/reporting-consumer-status.yaml) | Capability-gated buyer-to-seller status sync, missing-obligation identity, caller-scoped mismatch, and immutable supersession | +| [`reliable_reporting_managed_delivery`](/compliance/latest/universal/reliable-reporting-managed-delivery.yaml) | Capability-gated Managed Delivery declaration, including an atomic managed offering plus resource-retention and authorization-revocation guarantees | +| [`reliable_reporting_reconciled_billing`](/compliance/latest/universal/reliable-reporting-reconciled-billing.yaml) | Capability-gated Reconciled Billing declaration, including canonical evidence and authenticated revision/adjustment receipts | +| [`canonical_format_validate_input`](/compliance/latest/universal/canonical-format-validate-input.yaml) | Canonical-format [`validate_input`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/creative/validate-input-request.json) result semantics: structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | +| [`ctv_experience_validate_input`](/compliance/latest/universal/ctv-experience-validate-input.yaml) | [CTV experience profile](/dist/docs/3.3.0-beta.3/creative/ctv-experiences) `validate_input` semantics: the six `ctv_ad_experience` values against their permitted canonicals, the `overlay`/`squeezeback` duration floor, `in_scene`'s no-interactivity rule, the menu focus/video slot pairing, and non-blocking activation-copy warnings. Gated on agents that advertise `validate_input` and `comply_test_controller` seeding support. | +| [`premium_display_canonical_validation`](/compliance/latest/universal/premium-display-canonical-validation.yaml) | AdCP 3.2 premium-display conformance: bounded state transitions, exact state/breakpoint canvas coverage, coordinated placement/component resolution, namespaced asset coverage, and shared-slot references. | +| [`security_baseline`](/compliance/latest/universal/security.yaml) | Unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding | +| [`oauth_setup`](/compliance/latest/universal/oauth-setup.yaml) | Complete RFC 9728/RFC 8414 discovery graph, issuer/resource consistency, and passive endpoint reachability — skipped unless `oauth.supported: true` | +| [`trusted_match_publisher_authentication`](/compliance/latest/universal/trusted-match-publisher-authentication.yaml) | TMP router deployment authentication — selected only for agents declaring `trusted_match.core` in `experimental_features`; a credential-profile-neutral runner sends schema-valid Context Match and Identity Match requests with absent and invalid credentials and requires HTTP `401` with `WWW-Authenticate`; a selected deployment with unavailable probe configuration produces `requirement_unmet` | +| [`webhook_emission`](/compliance/latest/universal/webhook-emission.yaml) | Outbound webhook conformance — stable `idempotency_key` across retries, RFC 9421 webhook signing (or opt-in HMAC fallback) on every delivery. Runs for any agent accepting `push_notification_config`. | +| [`webhook_receiver_envelope`](/compliance/latest/universal/webhook-receiver-envelope.yaml) | Buyer receiver replay conformance: accepts full MCP webhook envelopes, rejects bare delivery-result payloads, verifies signatures over raw body bytes, and dedupes retries by `idempotency_key`. | +| [`agent_notification_configs`](/compliance/latest/universal/agent-notification-configs.yaml) | Agent-level `capabilities.changed` registration: revision-fenced capability declaration, NO_AUTH rejection, and dry-run clear semantics for [`sync_agent_notification_configs`](/dist/docs/3.3.0-beta.3/protocol/sync_agent_notification_configs) | +| [`principal`](/compliance/latest/universal/principal.yaml) | Stable-principal connection configuration for [`sync_principal`](/dist/docs/3.3.0-beta.3/protocol/sync_principal) and [`get_principal`](/dist/docs/3.3.0-beta.3/protocol/get_principal): experimental capability declaration, NO_AUTH rejection, dry-run validation, applied lifecycle with readback, idempotent replay, and section clearing | +| [`notification_config_event_scope`](/compliance/latest/universal/notification-config-event-scope.yaml) | `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 | +| [`account_identity_reconciliation`](/compliance/latest/universal/account-identity-reconciliation.yaml) | Deprecated in 3.3 with `account.identity_updates`; runs only for sellers that still declare it. Capability-gated account identity lifecycle: mandatory revision fences, stale-write atomicity, in-place operator-unit rekeying, collision rollback, preserved account IDs, and authorized former-key [`ACCOUNT_MOVED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-account-moved) recovery | +| [`notification_config_lifecycle`](/compliance/latest/universal/notification-config-lifecycle.yaml) | Account-level `notification_configs[]` lifecycle on [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts): paused registration, durable [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) echo, subscriber-keyed replacement, and clear-all semantics | +| [`notification_config_rejections`](/compliance/latest/universal/notification-config-rejections.yaml) | Semantic `notification_configs[]` request rejection path for duplicate `subscriber_id` values | +| [`wholesale_feed_products`](/compliance/latest/universal/wholesale-feed-products.yaml) | Product wholesale feed versioning: bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without product rows | +| [`wholesale_feed_signals`](/compliance/latest/universal/wholesale-feed-signals.yaml) | Signals wholesale feed versioning: bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without signal rows | +| [`wholesale_feed_products_scope_isolation`](/compliance/latest/universal/wholesale-feed-products-scope-isolation.yaml) | Product wholesale feed cache-scope isolation: a `wholesale_feed_version` minted under one `cache_scope` must not short-circuit (`unchanged: true`) a request the seller resolves to a different `cache_scope` | +| [`wholesale_feed_signals_scope_isolation`](/compliance/latest/universal/wholesale-feed-signals-scope-isolation.yaml) | Signals wholesale feed cache-scope isolation: a `wholesale_feed_version` minted under one `cache_scope` must not short-circuit (`unchanged: true`) a request the agent resolves to a different `cache_scope` | +| [`wholesale_feed_product_webhooks`](/compliance/latest/universal/wholesale-feed-product-webhooks.yaml) | Account-level `notification_configs[]` registration for agents that advertise product wholesale feed webhook events | +| [`wholesale_feed_signal_webhooks`](/compliance/latest/universal/wholesale-feed-signal-webhooks.yaml) | Account-level `notification_configs[]` registration for agents that advertise signal wholesale feed webhook events | +| [`wholesale_feed_bulk_webhooks`](/compliance/latest/universal/wholesale-feed-bulk-webhooks.yaml) | Account-level `notification_configs[]` registration for agents that advertise `wholesale_feed.bulk_change` | +| [`pagination_integrity`](/compliance/latest/universal/pagination-integrity.yaml) | `cursor` ↔ `has_more` invariant on paginated [`list_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives) responses, walked from a continuation page through to a terminal page | +| [`get_products_pagination_integrity`](/compliance/latest/universal/get-products-pagination-integrity.yaml) | [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products) wholesale pagination semantics: walks a seeded product feed from continuation to terminal while preserving brief/refine as capped curated/refined answers, not feed enumeration | +| [`get_signals_pagination_integrity`](/compliance/latest/universal/get-signals-pagination-integrity.yaml) | `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`](/compliance/latest/universal/pagination-integrity-list-accounts.yaml) | Continuation-side pagination integrity on `list_accounts` responses; storyboard bootstraps three sandbox accounts via `sync_accounts`, requires `has_more=true` with a usable cursor on the first page, and follows that cursor once | +| [`pagination_integrity_creative_formats`](/compliance/latest/universal/pagination-integrity-creative-formats.yaml) | Deprecated named-format compatibility check: `cursor` ↔ `has_more` invariant when an agent still exposes [`list_creative_formats`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creative_formats) | +| [`get_media_buys_pagination_integrity`](/compliance/latest/universal/get-media-buys-pagination-integrity.yaml) | `cursor` ↔ `has_more` invariant on paginated [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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`](/compliance/latest/universal/content-standards-pagination-integrity.yaml) | `cursor` ↔ `has_more` invariant on paginated [`list_content_standards`](/dist/docs/3.3.0-beta.3/governance/content-standards/tasks/list_content_standards) responses; storyboard bootstraps three content standards configurations via [`create_content_standards`](/dist/docs/3.3.0-beta.3/governance/content-standards/tasks/create_content_standards) and walks from a continuation page to a terminal page | +| [`pagination_integrity_collection_lists`](/compliance/latest/universal/collection-lists-pagination-integrity.yaml) | `cursor` ↔ `has_more` invariant on paginated [`list_collection_lists`](/dist/docs/3.3.0-beta.3/governance/collection/tasks/collection_lists#list_collection_lists) responses; storyboard bootstraps three collection lists via [`create_collection_list`](/dist/docs/3.3.0-beta.3/governance/collection/tasks/collection_lists#create_collection_list) and walks from a continuation page to a terminal page | +| [`pagination_integrity_property_lists`](/compliance/latest/universal/property-lists-pagination-integrity.yaml) | `cursor` ↔ `has_more` invariant on paginated [`list_property_lists`](/dist/docs/3.3.0-beta.3/governance/property/tasks/property_lists#list_property_lists) responses; storyboard bootstraps three property lists via [`create_property_list`](/dist/docs/3.3.0-beta.3/governance/property/tasks/property_lists#create_property_list) and walks from a continuation page to a terminal page | +| [`property_list_reference_resolution`](/compliance/latest/universal/property-list-reference-resolution.yaml) | Resolves an issued property list with only its scoped bearer token in a fresh session, walks all identifier pages, and rejects cross-list reads and mutations. Requires runner support for `property_list_reference`; unsupported runners must report a coverage gap. | +| [`deterministic_testing`](/compliance/latest/universal/deterministic-testing.yaml) | `comply_test_controller` state machine — skipped if `capabilities.compliance_testing.supported: false` | +| [`signed_requests`](/compliance/latest/universal/signed-requests.yaml) | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. | +| [`billing_gate_dispatch`](/compliance/latest/universal/billing-gate-dispatch.yaml) | Two-gate dispatch on `sync_accounts.billing` rejection: seller-wide capability gate ([`BILLING_NOT_SUPPORTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-billing-not-supported) with `error.details.scope`) vs per-buyer-agent commercial-relationship gate ([`BILLING_NOT_PERMITTED_FOR_AGENT`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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.3.0-beta.3/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.3.0-beta.3/building/by-layer/L1/security#signed-requests-transport-layer); a partial verifier is non-conformant, so declare `false` rather than ship one. + +Agents that declare `oauth.supported: true` MUST serve a consistent public OAuth discovery graph. The baseline grade is passive: it does not register a client, issue credentials, or perform an authorization flow. + +### Proposed AdCP 4.0 async webhook baseline + +**Status: proposal (`async-webhooks/v1`), not part of any released 3.x suite or current AgenticAdvertising.org Verified badge.** This is a versioned conformance target, not a third grading profile alongside Legacy and Strict Spec. This proposal adds no failure for 3.x agents that poll where their version permits it. The requirement becomes operative only after its event catalog, registration surfaces, executable storyboards, migration notice, and a hosted HTTPS test receiver ship in a new versioned compliance bundle. + +The proposed baseline applies to every declared protocol and specialism through the same rules: + +| Situation | Proposed requirement | Repair read | +|---|---|---| +| An operation can return `submitted`, `working`, or `input-required` | Accept a buyer-supplied `push_notification_config`; deliver at least one signed terminal `completed`, `failed`, or `canceled` event with the originating `task_id`, caller's `operation_id`, and stable delivery `idempotency_key`. Intermediate progress events may remain optional. | [`get_task_status`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/protocol/get-task-status-request.json) or the operation's documented task-status read | +| An account, buy, report, creative, right, governance resource, or signal can change after the initial response | Accept a durable subscriber on the registration surface defined for that event family; deliver signed change or invalidation events for the versioned required event matrix. An event is an invalidation signal unless its schema explicitly defines a complete replacement. | The documented authoritative read for that resource | +| An operation returns its terminal result inline and no later state change is subscribed | No task-completion webhook is required or expected. | The inline result | + +Buyers may omit the callback registration and use polling. Agents seeking the proposed profile must accept a valid callback registration when offered for an applicable operation; a successful `submitted` response cannot silently discard it. A 3.x polling-only seller remains eligible under its existing version's rules. A seller that emits webhooks today still follows the current `webhook_emission` signing and delivery rules. + +The required delivery profile is RFC 9421 `adcp/webhook-signing/v1`, advertised with `webhook_signing.supported: true` and backed by a published JWKS key. The deprecated HMAC fallback is not an alternative pass path for this profile. Delivery must preserve the caller's `context`, reuse the event's `idempotency_key` on retries, and support buyer deduplication and pull reconciliation. The exact retry horizon and each protocol's required event names belong in the versioned event matrix before activation. + +The runner must register a real, run-scoped HTTPS receiver and verify receipt, correlation, signature, and terminal result against polling. It must grade missing receiver infrastructure as **platform-incomplete**, not as a seller failure or a webhook pass. A storyboard that exercises only polling, including the current `sales-guaranteed` approval path, cannot establish conformance to this proposed baseline. + +Before activation, publish a versioned event matrix with one row per required event family. Each row must name the emitting protocol and operation, registration field or task, event schema, triggering state change, terminal or retry expectation, and authoritative repair read. At minimum, resolve the existing account status, media-buy lifecycle, reporting revision, creative approval, rights revocation, governance-list change, and product/signal catalog event families. Add an executable negative case for a seller that accepts a callback but never delivers; the suite must fail that seller under `async-webhooks/v1` without changing any 3.x result. + +## Protocol conformance + +A `supported_protocols` claim obligates the protocol's baseline storyboard. + +| `supported_protocols` | Storyboard | +|-----------------------|------------| +| `media_buy` | [`media_buy_seller`](/compliance/latest/protocols/media-buy/index.yaml) + [`media_buy_state_machine`](/compliance/latest/protocols/media-buy/state-machine.yaml) | +| `creative` | [`creative_lifecycle`](/compliance/latest/protocols/creative/index.yaml) | +| `signals` | [`signals_baseline`](/compliance/latest/protocols/signals/index.yaml) | +| `governance` | [`media_buy_governance_escalation`](/compliance/latest/protocols/governance/index.yaml) | +| `brand` | [`brand_baseline`](/compliance/latest/protocols/brand/index.yaml) | +| `sponsored_intelligence` | [`si_baseline`](/compliance/latest/protocols/sponsored-intelligence/index.yaml) | + +## 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.3.0-beta.3/building/verification/compliance-catalog). + +Specialisms carry a `status`: `stable`, `preview`, or `deprecated` (scheduled for removal). The runner grades preview claims the same way as stable ones, but only stable claims count toward AAO Verified badges, and a preview specialism can change in a patch release. Agents MAY claim preview specialisms. See [specialism status](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#specialisms) for what each status means and when a preview specialism is promoted. + +## 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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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.3.0-beta.3/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`](/compliance/latest/universal/security.yaml) (identity) + isolation checks in protocol storyboards | Personnel access reviews, least-privilege admin, offboarding | +| Change management (SOC 2 CC8) | [`idempotency`](/compliance/latest/universal/idempotency.yaml) 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`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/create_media_buy) responses validate. + +## How to claim conformance + +1. Declare `supported_protocols` and `specialisms` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/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.3.0-beta.3/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.3.0-beta.3/learning/overview) runs on top of this; conformance is necessary but not sufficient. +- **Duplicate schemas and storyboards as per-task vectors.** The [Reference Test Vectors index](/dist/docs/3.3.0-beta.3/reference/test-vectors) catalogs byte-sensitive and library-level fixtures. Per-task shapes remain owned by JSON Schema, while task behavior remains owned by storyboards; a parallel frozen request/response corpus would create a third authority. SDK authors can derive local fixtures from the versioned storyboard when they need isolated unit tests. + +## 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.3.0-beta.3/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.3.0-beta.3/building/cross-cutting/known-ambiguities) instead of treating the mock as authoritative. + +- **[Storyboard troubleshooting](/dist/docs/3.3.0-beta.3/building/operating/storyboard-troubleshooting)** — Error pattern → root cause → fix for the most common storyboard failures +- **[Known spec ambiguities](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/verification/aao-verified)** — Continuous-observability verification of the seller's live ad-server integration +- **[Compliance Catalog](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog)** — Full taxonomy of protocols and specialisms with storyboard IDs +- **[Validate Your Agent](/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent)** — How to run the suite +- **[Security Model](/dist/docs/3.3.0-beta.3/building/concepts/security-model)** — Strategic framing for the five defense layers that the security storyboards enforce +- **[Security (implementation reference)](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security)** — Normative rules cited by the storyboards +- **[Versioning](/dist/docs/3.3.0-beta.3/reference/versioning)** — Major-version support windows +- **[Known Limitations](/dist/docs/3.3.0-beta.3/reference/known-limitations)** — Visible edges of the specification diff --git a/dist/docs/3.3.0-beta.3/building/verification/get-test-ready.mdx b/dist/docs/3.3.0-beta.3/building/verification/get-test-ready.mdx new file mode 100644 index 00000000000..40229ef3e28 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/verification/get-test-ready.mdx @@ -0,0 +1,246 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/accounts/tasks/sync_accounts) (or [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts)) | How to obtain a sandbox account to run tests against | Your agent's account tool | +| [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller) | How to force seller-side transitions deterministically and, when supported, inject fixtures | 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`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) is how the runner picks which storyboards apply to you. It is also the [conformance](/dist/docs/3.3.0-beta.3/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. + + +The current 3.x `media_buy_seller` baseline uses non-guaranteed fixtures in its core flow and several inherited scenarios. Some workflows require an immediately issued `media_buy_id`; others assert immediate preflight rejection or are capability-gated. A guaranteed-only seller can pass the `sales-guaranteed` approval storyboard yet still lack a complete media-buy grade. A versioned, delivery-mode-aware baseline must preserve synchronous checks for non-guaranteed buys and add task completion and readback for guaranteed buys. + + +```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.3.0-beta.3/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.3.0-beta.3/building/verification/conformance) badge buyer agents use to filter sellers — overclaim once, lose inclusion everywhere. + + +## Step 2 — Pick your sandbox bootstrap path + +The runner must obtain a sandbox account before it can do anything. Your `require_operator_auth` flag chooses the path: + +**Buyer-declared accounts (`require_operator_auth: false`).** Your agent accepts [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) from any authenticated buyer. The runner calls `sync_accounts` with `sandbox: true` to mint a test account on demand. Most new sales agents start here. + +**Account-id namespaces (`require_operator_auth: true`).** Accounts must be pre-provisioned by a human on your side or by an upstream platform. The runner calls [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) with a sandbox filter to discover pre-existing test accounts. If your credential is bound to exactly one sandbox account, return that singleton. Publish a short note telling operators how to request one — include the contact, the expected turnaround, and what credentials they'll receive. + + +When your response includes the `account` capability block, its `require_operator_auth` flag — not the presence of `list_accounts` — selects the account model. Keep the flag `false` (or omit the flag, since `false` is its schema default) for buyer-declared natural-key accounts even if your agent also exposes `list_accounts`. Do not omit the entire `account` block: without account capability data, runners may retain a legacy `list_accounts` discovery heuristic. Set the flag to `true` only when callers must use seller-assigned account IDs. In that mode, the sandbox credential must expose one unambiguous account for the storyboard brand; if it exposes a roster, make sure exactly one row matches the test kit's `brand.house.domain`. Do not advertise `false` merely to bypass an ambiguous roster — that tells buyers the wrong account model. + + +Full details and examples: [Sandbox mode](/dist/docs/3.3.0-beta.3/media-buy/advanced-topics/sandbox). + +## Step 3 — Implement the compliance test controller + +Without a compliance test controller, the runner tests only buyer-initiated flows (**observational mode**) — schema conformance, auth rejection, happy-path buyer calls. That is enough for early integration work and production-path sandbox smoke tests, but it produces **partial coverage** whenever controller-seeded or controller-forced scenarios are in scope. [Conformance](/dist/docs/3.3.0-beta.3/building/verification/conformance) treats **deterministic mode** — full lifecycle walks enabled by the controller — as the bar for complete specialism coverage. + +In practice, sellers usually run both: + +| Run | Endpoint | Expected result | +|-----|----------|-----------------| +| Observational sandbox run | Production endpoint with sandbox accounts, no controller exposed | `steps_failed = 0`; live-only probes appear as not selected, and selected controller-dependent scenarios may make the summary `partial` with `requirement_unmet` (`controller`) or `missing_test_controller` skips | +| Deterministic run | Dev/staging or sandbox-only endpoint with [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller) | `steps_failed = 0` and no controller-dependent coverage skips | + +The first run answers "does the production buyer-visible path tolerate sandbox traffic?" The second answers "can the runner prove every lifecycle path for the declared specialisms?" Do not collapse those into one signal. A zero-failure partial run is useful, but buyers need the skip list to know exactly which lifecycle assertions were not graded. + +`comply_test_controller` is a single sandbox-only tool with a `scenario` parameter covering three families: + +| Scenario family | What it does | When you need it | +|-----------------|--------------|------------------| +| `seed_*` | Create fixtures (products, pricing options, creatives, plans, media buys) with caller-supplied IDs | Seed-only storyboards and fixture-store sellers; 3.2 storyboards may instead resolve explicitly declared requirements through normal discovery or construction | +| `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.3.0-beta.3/building/by-layer/L3/comply-test-controller) for scenario-by-scenario parameters and response shapes, and the [Compliance Catalog](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog) for which scenarios each specialism requires. + +If your products come from a publisher-owned catalog and use seller-issued or +derived IDs, do not add a test-only alias layer merely to accept storyboard +literals. For storyboards that publish `fixture_resolution` rules, provision +representative inventory through your normal platform path and let the runner +discover and bind it. The run will report any requirement your sandbox catalog +cannot satisfy as `fixture_unsatisfied`. Storyboards that have not yet migrated +remain seed-only, so inspect the coverage report rather than assuming discovery +applies suite-wide. + +### 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.3.0-beta.3/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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#error-codes).** Scenarios you do not register return `UNKNOWN_SCENARIO` automatically — never a schema error. +- **Param validation.** Invalid params produce [`INVALID_PARAMS`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#error-codes) with a readable `error_detail` without reaching your adapter. +- **Seed idempotency.** Calling `seed_product` twice with the same `product_id` and an equivalent `fixture` returns `previous_state: "existing"`; a divergent `fixture` returns `INVALID_PARAMS`. Your adapter is only invoked on the first seed. +- **Typed error envelopes.** Throw `TestControllerError(code, message, currentState?)` with `code` from the controller error-code table. Common adapter codes are `'INVALID_TRANSITION' | 'NOT_FOUND' | 'FORBIDDEN' | 'INVALID_PARAMS'`; digest-mode `query_upstream_traffic` implementations may also return [`JCS_NON_FINITE_NUMBER`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#error-codes) when RFC 8785/JCS canonicalization encounters a non-finite numeric value. + +The scaffold does **not** own the state machine. Transition rules live in your adapters, so compliance testing and production share one source of truth — the mechanic the [anti-teach-to-test section](#avoiding-the-teach-to-test-trap) depends on. + +### Two layers of sandbox gating + +The scaffold supports two gates. Ship both in any deployment that serves both sandbox and production traffic from the same process: + +1. **Registration gate (primary).** Wrap `controller.register(server)` in an environment check. This is what keeps `comply_test_controller` out of production `tools/list` entirely. Without it, a leaked sandbox credential on a production endpoint exposes seller-side state-forcing. +2. **Per-request gate (defense-in-depth).** Pass a `sandboxGate: (input) => boolean` to `createComplyController`. The scaffold calls it on every request and returns [`FORBIDDEN`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#error-codes) 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.3.0-beta.3/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. + +## Debug a failed storyboard + +Start with the first failing step; later failures are often consequences of lost state. Check these recurring classes before changing protocol behavior: + +| Failure | What to inspect | +|---|---| +| Capability storyboard skips or selects the wrong tracks | `get_adcp_capabilities.supported_protocols` and `specialisms[]` must describe only the surface actually implemented | +| Version-negotiation failure | Keep legacy `adcp.major_versions`, add release-precision `adcp.supported_versions`, and echo the served release as envelope-level `adcp_version` | +| Missing status or malformed response | Put `status` and `adcp_version` on the protocol envelope, not inside the task payload; validate the raw wire response against the referenced response schema | +| A later step cannot resolve an ID | Preserve the prior step's captured entity and account scope; do not replace storyboard-generated IDs with fixture literals | +| Error-path context mismatch | Echo request `context` on success and error branches so correlation survives validation, authorization, and state errors | +| Authenticated steps return 401 while discovery works | [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) is public, but the reverse proxy must forward the `Authorization` header unchanged on authenticated task calls | + +Run the single failed storyboard with debug output before rerunning the full suite. The step's `schema_ref`, `response_schema_ref`, validation path, and raw response identify whether the defect is transport, envelope shape, state continuity, or capability declaration. + +## Readiness checklist + +Before your first full storyboard sweep: + +- [ ] [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) returns only protocols and specialisms you actually implement +- [ ] Capabilities include both `adcp.major_versions` and release-precision `adcp.supported_versions`; responses echo the served `adcp_version` at envelope level +- [ ] Success and error responses preserve request `context`, including `correlation_id` +- [ ] Your ingress/proxy preserves `Authorization` on authenticated task calls +- [ ] `account.sandbox: true` is declared and honored — sandbox requests produce no real spend, no production platform calls, no persisted production state +- [ ] [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) (implicit) or [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_accounts) (explicit) handles sandbox requests per step 2 +- [ ] [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller) is absent from `tools/list` on any production endpoint +- [ ] Requests that reference a non-sandbox account are rejected with [`FORBIDDEN`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#error-codes) +- [ ] 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.3.0-beta.3/building/verification/validate-your-agent)** — CLI, Addie workflows, and multi-instance verification +- **[Compliance test controller reference](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller)** — Full scenario-by-scenario spec +- **[Sandbox mode](/dist/docs/3.3.0-beta.3/media-buy/advanced-topics/sandbox)** — The two account model paths in depth +- **[Conformance](/dist/docs/3.3.0-beta.3/building/verification/conformance)** — What "conformant" and "verified" mean once your runs pass diff --git a/dist/docs/3.3.0-beta.3/building/verification/grading.mdx b/dist/docs/3.3.0-beta.3/building/verification/grading.mdx new file mode 100644 index 00000000000..d5de66aed7f --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/verification/grading.mdx @@ -0,0 +1,87 @@ +--- +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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/protocol/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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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 \ + --vector /path/to/vector.json \ + --keys /path/to/keys.json +``` + +Reads the local vector and key-set JSON files and runs the SDK reference verifier. For a self-contained negative vector, compare the reported rejection code with the fixture's `expected_outcome.error_code`; the CLI does not grade that comparison for you and exits with status 1 for an expected rejection. Shell scripts and CI jobs must therefore compare the output rather than treating every nonzero exit as an unexpected tool failure. + +Some negative fixtures declare `test_harness_state` and are not self-contained. For example, `016-replayed-nonce.json` requires the verifier harness to preload a replay-cache entry. A local white-box harness can apply the fixture's declared state; live black-box certification uses the full grader's coordinated repeat-request contract. See the request-signing guide's [downloadable vector example](/dist/docs/3.3.0-beta.3/building/by-layer/L1/request-signing#conformance-vectors) for a copyable CDN flow. + +**When to use it:** while implementing or debugging a verifier to isolate canonicalization and validation rules before testing end-to-end. + +## Related + +- [Validate Your Agent](/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent) — storyboard-based protocol compliance testing +- [Authentication](/dist/docs/3.3.0-beta.3/building/by-layer/L2/authentication) — auth model overview, bearer tokens, RFC 9421 introduction +- [Security implementation reference](/dist/docs/3.3.0-beta.3/building/by-layer/L1/security#signed-requests-transport-layer) — full RFC 9421 profile, verifier checklist, key publication rules diff --git a/dist/docs/3.3.0-beta.3/building/verification/how-grading-works.mdx b/dist/docs/3.3.0-beta.3/building/verification/how-grading-works.mdx new file mode 100644 index 00000000000..be37940512a --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/verification/how-grading-works.mdx @@ -0,0 +1,209 @@ +--- +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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities), the runner: + +1. Fetches the specialism manifest at `/compliance/{version}/specialisms/{id}/`. +2. Reads the manifest's `requires_scenarios` list — an ordered set of scenario IDs the runner must grade. +3. For each scenario, checks whether the scenario declares a `requires_capability` gate. +4. If a gate is present, reads the named path from your `get_adcp_capabilities` response to decide whether to run or skip the scenario. + +The manifest drives the full scenario list; capability gates apply per-scenario on top of it. + +## Runner evidence vs. verification policy + +The storyboard runner does not decide whether an agent earns a badge or satisfies a particular buyer. It produces evidence: executed assertions, failures, selected-but-skipped steps, not-selected steps, skip reasons, and the endpoint/run mode used. A verification policy consumes that evidence and decides which gaps are acceptable for a named outcome. + +| Question | Decided by | Evidence used | +|----------|------------|---------------| +| "Did this step match the storyboard?" | Storyboard runner | Step validations and response schemas | +| "Does this agent earn **Verified (Spec)**?" | AgenticAdvertising.org verification policy | Runner evidence against the registered spec/test endpoint, plus membership and declaration checks | +| "Does this agent earn **Verified (Sandbox)**?" | AgenticAdvertising.org verification policy | Runner evidence against the production endpoint under `account.sandbox: true`, plus sandbox-isolation checks | +| "Is this good enough for a specific buyer?" | That buyer's requirement profile | Verified mode, declared protocols/specialisms, optional capabilities they require, and any business or integration requirements outside AdCP | + +This is why runner classification matters. "Not selected because this is a sandbox-only run" is a suite-selection fact, not a seller capability gap. "Skipped because the seller did not claim an optional feature" may be acceptable for the badge but unacceptable to a buyer that requires that feature. "Skipped because a declared required tool is missing" is a seller implementation problem. + +## Requirement profiles + +To answer "what do I need to pass?", start with the profile you are trying to satisfy: + +| Target | Minimum question the profile answers | +|--------|--------------------------------------| +| **Verified (Spec)** | Does the declared AdCP surface pass the required storyboards on the registered spec/test endpoint, with no blocking missing tools or failed assertions? | +| **Verified (Sandbox)** | Does the registered production endpoint pass the sandbox verification profile under `account.sandbox: true`, with sandbox isolation enforced and only Sandbox-acceptable skips present? | +| **Specific buyer requirement** | Which verified modes, protocols, specialisms, optional capabilities, operational behaviors, and skip classes does this buyer require? | + +A buyer requirement profile can be stricter than the public badge. For example, the public Sandbox profile can accept a capability-gated proposal storyboard being skipped when `media_buy.supports_proposals: false`; a buyer that requires proposal workflows can treat the same skip as a blocker. Conversely, a production endpoint omitting [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller) can be acceptable for the Sandbox badge, while a buyer doing deterministic integration testing may ask for a separate dev/staging endpoint that exposes it. + +The storyboard runner should not encode those buyer decisions. It should emit typed evidence that a verification policy or buyer profile can evaluate consistently. + +## Specialism manifests + +Each specialism's `requires_scenarios` field lists the scenarios the runner considers alongside the parent protocol baseline. For example, the `sales-guaranteed` manifest includes these 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/measurement_terms_accepted + - 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 + - media_buy_seller/proposal_finalize_asap_timing + - media_buy_seller/availability_windows + - media_buy_seller/proposal_not_found_errors + - governance_aware_seller/governance_multi_agent_rejected +``` + +Capability gates on individual scenarios still apply; for example, `proposal_finalize` requires `media_buy.supports_proposals: true`. The parent `media_buy_seller` baseline also runs, including scenarios outside this list. + +## Capability gates + +A scenario or phase can declare a `requires_capability` block. The runner reads the named path from your [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) response and checks it against the expected value. If the check fails (the capability is absent or false), the scenario or phase is skipped — the `skip` block will appear in runner output with `reason: not_applicable` — and does not contribute to `steps_failed`. + +```yaml +# /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`](/compliance/latest/universal/capability-discovery.yaml) storyboard. + +Phase-level gates use the same shape and scope only the phase that declares the block. Universal storyboards use this for protocol-specific phase families, for example deterministic testing skips its SI session phase when an agent does not declare `sponsored_intelligence` while still running media-buy or creative phases the agent does declare. + +## Worked example + +**Scenario:** Priya's StreamHaus platform claims `sales-guaranteed` and declares `media_buy.supports_proposals: true`. + +```json +{ + "supported_protocols": ["media_buy"], + "specialisms": ["sales-guaranteed"], + "media_buy": { + "supports_proposals": true + } +} +``` + +**Runner behavior:** applicable required scenarios run, including `proposal_finalize`. Priya's platform is graded on the full proposal lifecycle — brief with proposals, refine, finalize, and execute via [`create_media_buy`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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. + +This flag is only the grading gate. Buyer agents should not use it to decide whether a specific proposal is executable. If a seller returns a proposal, `proposal_status` is the source of truth: `draft` requires finalization before create, `committed` can be executed before `expires_at`, and absent status is legacy ready-to-buy. + + +## Grading verdicts at a glance + +| Outcome | Output field | Meaning | +|---------|--------------|---------| +| Scenario passed | Step result with no `skip`; `passed: true` | All validations passed | +| Scenario failed | Step result with no `skip`; `passed: false` | One or more required validations failed; see `validations[]` for the failing field and `json_pointer` | +| Scenario not selected | `summary.not_selected[].reason`, counted in `summary.steps_not_selected` | The caller's selected suite, run mode, version, or verification profile excluded this scenario before execution | +| Scenario skipped | `skip.reason: not_applicable` | Scenario was selected, but an applicability gate evaluated false, such as an optional capability the seller did not claim | +| Required tool missing | `skip.reason: missing_tool` | Scenario was selected and the agent declared the specialism, but did not expose a tool listed in `required_tools` | +| Raw storyboard assertion failed | `StoryboardResult.assertions[]` entry with `passed: false` | A storyboard invariant failed; a storyboard-scoped failure can fail the storyboard even when every individual step passed | + +In a raw `StoryboardResult`, `overall_passed` combines executed-step results with [storyboard invariants](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#cross-resource-invariants). A storyboard-scoped assertion can set `overall_passed: false` even when `failed_count` — and the aggregate `summary.steps_failed` count — is zero. Inspect `assertions[]` for its `assertion_id`, `scope`, optional `step_id`, and `error` or structured `hint`. Step-scoped assertion failures also appear in that step's `validations[]` with `check: "assertion"`. + +As of `@adcp/sdk@13.0.0`, the compliance aggregate preserves storyboard-scoped assertion failures as structured error observations and represents the failed invariant as a storyboard-level scenario. The containing track cannot report `pass`, while `summary.steps_failed` remains a truthful step counter and can stay zero when no individual step failed. Consumers should inspect the structured observation or storyboard-level scenario for the failed `assertion_id`; do not fabricate a failed step for an `onEnd`-only invariant. + +Skipped steps (`skip` block present) and not-selected items (`summary.not_selected[]` entries) do not contribute to `summary.steps_failed`, but they mean different things. `summary.steps_not_selected` says the runner deliberately excluded those scenarios from this run. `summary.steps_skipped` says the runner selected those scenarios but could not execute them; use `summary.skipped_by_reason` and `skip.detail` to distinguish optional capability choices from missing required surfaces. For ordinary step validation failures, see [Storyboard troubleshooting](/dist/docs/3.3.0-beta.3/building/operating/storyboard-troubleshooting). + +A sandbox-only run should therefore look like this when the only excluded work is outside the selected mode: + +```json +{ + "summary": { + "steps_passed": 84, + "steps_failed": 0, + "steps_skipped": 0, + "steps_not_selected": 80, + "not_selected_by_reason": { + "run_mode_excluded": 80 + } + } +} +``` + +If those 80 items instead appear under `steps_skipped`, they were selected and then skipped; that is a different signal and needs `skipped_by_reason`. + +## Pass vs partial coverage + +Runner summaries distinguish **failure** from **coverage**: + +| Run shape | What it means | Seller action | +|-----------|---------------|---------------| +| `summary.steps_failed > 0` | One or more executed steps failed a required validation, including a step-scoped assertion | Fix the agent before claiming the corresponding protocol or specialism | +| `summary.steps_failed = 0`, no coverage-gap skips, and every raw `StoryboardResult.overall_passed` is `true` | Full pass for the declared scope | The declared protocols, specialisms, and capability flags were graded cleanly | +| `summary.steps_failed = 0` with a failed `StoryboardResult.assertions[]` entry | The raw storyboard failed despite passing step counters; the rc15 aggregate does not preserve this verdict | Fix the invariant failure and treat the run as failed; do not rely on the aggregate status | +| `summary.steps_failed = 0`, with `summary.steps_not_selected > 0` only | The requested suite or run mode intentionally excluded some probes, such as live-only checks during a sandbox-only run | No implementation ask; label the run mode clearly | +| `summary.steps_failed = 0`, with only capability-gate `not_applicable` skips | Narrower declared scope, not a failure | The agent honestly declined an optional capability, such as `media_buy.supports_proposals: false` | +| `summary.steps_failed = 0`, with `missing_test_controller` skips | Deterministic-test-surface coverage gap: the controller was available at load time but became unavailable mid-run, or a step calls it in a storyboard that does not gate on it | Run a dev/staging deterministic pass or publish the skipped lifecycle coverage explicitly | +| `requirement_unmet` skip naming `controller` on controller-seeded storyboards | The seller exposes no [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#who-is-expected-to-implement-the-controller); reference sellers SHOULD implement it, translator and adapter sellers that cannot seed state are not expected to | Reference sellers: implement the controller. Translator and adapter sellers: no ask; publish the skipped coverage list | +| Any `missing_tool`, `unsatisfied_contract`, or other `requirement_unmet` skip (except the `controller` row above) for a declared protocol or specialism | The seller declared a claim the runner could not fully test | Fix the missing surface or narrow the declaration | +| A declared specialism the graded compliance release does not define, because AdCP introduced it later | Reported `not_applicable` for that specialism; the rest of the run is graded normally, and the specialism neither earns nor blocks a badge for this release | No implementation ask — advertise a release that defines the specialism in `adcp.supported_versions` to have it graded and badged | + +This distinction matters for production-path sandbox runs. A seller can run the storyboard suite against the real production endpoint under sandbox-flagged traffic and get **zero failures** while still seeing a `partial` summary because the production endpoint correctly does not expose [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller). After confirming that the raw storyboard results contain no failed storyboard-scoped assertions, that result says: "the buyer-visible sandbox path passed every assertion the runner could grade, but controller-seeded lifecycle scenarios were skipped." It is useful evidence for sandbox readiness, but it is not the same as full deterministic specialism coverage. + +For full coverage, run the same declared scope against a dev or staging endpoint that exposes the controller, or pre-seed the required state and configure the runner to assert seeded-state coverage. For a production-only seller, publish the skipped coverage list alongside the zero-failure result so buyers can see exactly what was and was not graded. + +## Where each piece lives + +| Artifact | URL path | Source | +|----------|----------|--------| +| Specialism manifest | `/compliance/{version}/specialisms/{id}/` | `static/compliance/source/specialisms/{id}/index.yaml` | +| Scenario YAML | `/compliance/{version}/protocols/{protocol}/scenarios/{name}/` | `static/compliance/source/protocols/{protocol}/scenarios/{name}.yaml` | +| Universal storyboards | `/compliance/{version}/universal/` | `static/compliance/source/universal/` | +| Capabilities schema | `/schemas/3.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/verification/conformance) — the three-layer obligation model and the normative storyboard index +- [Compliance Catalog](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog) — full taxonomy of protocols, specialisms, and universal storyboards +- [Validate Your Agent](/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent) — running the suite locally with `@adcp/sdk` diff --git a/dist/docs/3.3.0-beta.3/building/verification/storyboards-vs-scenarios.mdx b/dist/docs/3.3.0-beta.3/building/verification/storyboards-vs-scenarios.mdx new file mode 100644 index 00000000000..931b707aa93 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/verification/storyboards-vs-scenarios.mdx @@ -0,0 +1,85 @@ +--- +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`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller) operations the runner uses to seed deterministic state + +[`conformance.mdx`](/dist/docs/3.3.0-beta.3/building/verification/conformance) calls these *"the truth"* — and means it. These YAML files are the basis for **AgenticAdvertising.org Verified (Spec)**; AgenticAdvertising.org issues that qualifier when its compliance heartbeat runs the applicable storyboards and every assertion passes. + +You'll see storyboards at: +- `/compliance/{version}/universal/*.yaml` — every AdCP agent must pass +- `/compliance/{version}/protocols/{protocol}/index.yaml` — anyone claiming the protocol +- `/compliance/{version}/specialisms/{id}/index.yaml` — opt-in claims + +Run them via `npx @adcp/sdk@latest storyboard run ` or interactively through [Addie](https://agenticadvertising.org). + +### Source authority and rollout + +The authored source of truth for storyboards is `static/compliance/source/` in the `adcontextprotocol/adcp` repo. `scripts/build-compliance.cjs` builds `dist/compliance/latest/` during development and stamps `dist/compliance/{version}/` on release, adding only generated distribution artifacts such as `index.json` and the legacy `domains/` alias. SDKs may bundle a snapshot so runners work offline and remain version-pinned, but that snapshot is a distribution copy, not the authority. + +During the current migration, `@adcp/sdk` still loads its bundled cache by default. Spec repo CI overlays `static/compliance/source/` onto that cache before running the training-agent storyboards so pull requests are graded against the candidate source without switching runtime authority in the same step. SDK release work should separately prove its bundled cache matches the spec bundle it claims to ship. + +Breaking or materially stricter storyboard changes must roll out in this order: + +```text +spec storyboard change -> reference implementations update -> @adcp/sdk runner release -> downstream consumers update +``` + +That order prevents the release deadlock where a new runner expects stricter scenarios before repo-owned reference implementations have a tagged implementation that can pass them. + +## `comply_test_controller` scenarios (normative — but different) + +The word "scenario" also appears in the [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/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.3.0-beta.3/building/by-layer/L3/comply-test-controller). Sellers MUST implement them to be testable; the storyboards MUST use them to stay deterministic. + +So when you hear "the seller doesn't support that scenario," it usually means: "the seller's `comply_test_controller` doesn't expose `force_X` yet." Not "no storyboard tests X." + +## `@adcp/sdk/testing/scenarios/*.ts` (NON-normative legacy) + +The SDK ships TypeScript files under `testing/scenarios/` — `media-buy.ts`, `signals.ts`, `creative.ts`, and a dozen siblings. These predate the storyboard-driven `comply()` engine. They are: + +- **Not the conformance spec.** Reading them to learn what AdCP requires will produce wrong answers. +- **Not maintained in lockstep with storyboards.** A scenario file may hard-code parameters that the storyboard equivalent has long since corrected. *Example: `scenarios/media-buy.ts` hard-codes `buying_mode: 'brief'` at four call sites; the YAML storyboard for `sales-guaranteed` exercises `buying_mode: 'refine'` + `action: 'finalize'` via the `proposal_finalize` storyboard. Both are valid `buying_mode` values; the scenario file just doesn't cover the lifecycle.* +- **Callable, but for different purposes.** Internal smoke tests in the SDK, integration-test fixtures for downstream tooling, and some legacy paths still use these. They are not what AgenticAdvertising.org Verified (Spec) runs against. + +If you find yourself grepping `testing/scenarios/*.ts` to understand AdCP, you've taken a wrong turn. The right path: + +1. Find the storyboard that matches your declared specialism: `npx @adcp/sdk@latest storyboard list` +2. Read the YAML: `npx @adcp/sdk@latest storyboard show ` +3. Run it: `npx @adcp/sdk@latest storyboard run --agent-url ` + +## Three-line decoder + +If you see… | …you're looking at | Trust as spec? +---|---|--- +A `.yaml` under `/compliance/{version}/...` | A storyboard | ✅ Yes +A seller responding to [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/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`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#error-codes)) +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.3.0-beta.3/building/verification/validate-with-mock-fixtures.mdx b/dist/docs/3.3.0-beta.3/building/verification/validate-with-mock-fixtures.mdx new file mode 100644 index 00000000000..0947a0575df --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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.3.0-beta.3/building/verification/validate-your-agent)) — it does not replace storyboards or staging integration tests, and is not a certification gate. The mock-fixture conventions described here (`/_lookup/`, `/_debug/traffic`) are reference implementations in `@adcp/sdk`; alternative SDK implementations may diverge. Treat as harness convention, not protocol contract. + + +Most AdCP agents that wrap an external platform (a DSP, SSP, retail data warehouse, creative server, signal marketplace) ship façade bugs that storyboards alone cannot detect: handlers return shape-valid AdCP responses without actually integrating with the upstream. The recipe on this page closes that gap as a **pre-staging gate** — cheap, fast, runs in CI, surfaces façades and contract drift before code reaches a staging tenant. + +If your agent does not wrap an upstream — for example, a pure decisioning service that owns its own data — the storyboard runner alone is sufficient. See [Validate your agent](/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent). + +## The four-step recipe + +```bash +# 1. Boot a mock upstream for your specialism +npx @adcp/sdk@latest mock-server sales-social --port 4250 & + +# 2. Run your AdCP agent, configured to use http://localhost:4250 as its upstream +./your-agent.sh # Python / Go / Rust / TS — language-agnostic + +# 3. Grade your agent against the matching storyboard +npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp sales_social \ + --auth $TOKEN --json > grader.json + +# 4. Assert your agent actually called the upstream +curl -s http://localhost:4250/_debug/traffic +# {"traffic": {"POST /oauth/token": 1, "POST /event/track": 6, ...}} +``` + +Step 3 catches AdCP wire bugs (response shape, error codes, idempotency, missing required fields). Step 4 catches **integration gaps** — agents whose handlers return shape-valid AdCP responses without exercising the upstream's headline endpoints. Both signals matter; one without the other is incomplete. + + +The mock-server CLI shown above ships in `@adcp/sdk` (TypeScript). If your CI runs in another language, the simplest pattern is to run the TS CLI as a sidecar (e.g. a Docker service container in GitHub Actions, or a separate Node process) — the agent under test stays in its native language, only the mock + storyboard runner are TS. A Python or Go SDK shipping its own mock-server would re-implement the conventions described below; until that lands, the TS CLI is the reference. + + +## Why traffic counters + +Storyboards check the AdCP wire contract: did the response match the schema, did the agent advertise the right tools, did context echo. They do not assert what happened *behind* the wire. The CLAUDE.md guidance for this repo puts it directly: **storyboards are assertions, not ground truth.** + +Adapters that look correct under shape-only validation but skip the upstream entirely have shipped to staging more than once. Common shapes: + +- Handler short-circuits before the upstream call when the input doesn't match a non-spec branch (e.g. empty `members[]` on [`sync_audiences`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/sync_audiences) → returns shape-valid empty response, never POSTs). +- OAuth client is wired but never invoked from any handler; tree-shaking is defeated with a `void fetchUpstreamToken;` literal. +- Synthetic placeholder data is injected to satisfy the upstream's required-field schema, masking real-data shape mismatches. + +A traffic counter on the mock upstream catches the first two cases unconditionally and the third case if the storyboard exercises the relevant payload variety. Position this in your CI as a **pre-staging gate**: cheap, deterministic, complements your existing staging tests rather than replacing them. + +## What's available + +The reference TS implementation (`@adcp/sdk`) ships four mock-server specialisms covering distinct upstream surface shapes. These are not exhaustive — they exist to exercise representative auth/tenancy/payload patterns, and other specialisms reuse the closest match. + +| Specialism | Mimics | Auth | Multi-tenant scope | +|---|---|---|---| +| `signal-marketplace` | LiveRamp / Lotame / data marketplace | Static Bearer | Header (`X-Operator-Id`) | +| `creative-template` | Celtra / Innovid creative-management platform | Static Bearer | Path (`/v3/workspaces/{ws}/…`) | +| `sales-social` | TikTok / Meta-shaped social ads platform | OAuth 2.0 client_credentials with refresh | Path (`/v1.3/advertiser/{advertiser_id}/…`) | +| `sales-guaranteed` | GAM / FreeWheel guaranteed-sales platform | Static Bearer | Header (`X-Network-Code`) | + +The auth shapes and tenancy patterns are realistic; the specific account-field names the adapter receives (e.g. `account.advertiser`, `account.operator`) are SDK conventions for binding AdCP requests to upstream tenants, not normative AdCP terms. See the `@adcp/sdk` source for the canonical mapping. + +Each mock exposes: + +- **The upstream's domain endpoints** — shaped to match the real platform's public contract. +- **`GET /_lookup/?=`** — runtime resolution from AdCP-side identifiers to upstream tenant IDs. *Harness convention.* +- **`GET /_debug/traffic`** — hit counters keyed by ` `, no auth, harness-only. *Harness convention.* Read after the storyboard run; assert each headline route is hit at least once. + +OpenAPI specs for each mock ship as part of the SDK package. Reference your adapter against the spec, not against any specific seed data — seeds vary and are not part of the contract. + +## CI integration + +Reference shape for a GitHub Actions job, language-agnostic: + +```yaml +jobs: + validate-adapter: + runs-on: ubuntu-latest + services: + mock-upstream: + image: node:20 + ports: ['4250:4250'] + # Use a bootstrap script that runs `npx @adcp/sdk@latest mock-server `. + steps: + - uses: actions/checkout@v4 + - run: ./scripts/start-agent.sh & # Your agent in your language + - run: ./scripts/wait-for-port.sh 3001 + - run: | + npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \ + --auth $TOKEN --json > grader.json + - run: | + # Assert each expected upstream route was hit at least once + curl -s http://localhost:4250/_debug/traffic | \ + jq -e '.traffic["POST /oauth/token"] >= 1 and .traffic["POST /event/track"] >= 1' +``` + +**Threshold guidance.** The minimum useful assertion is `≥ 1` per headline route — that proves the handler reached the upstream. Stronger assertions (per-route counts, distinct-payload verification) require encoding storyboard-payload expectations and aren't worth the maintenance burden in a pre-staging gate. If your storyboard exercises 3 audience uploads, expect 3 hits to `custom_audience/upload`; if it doesn't, the storyboard's payload coverage is the lever to pull, not the gate's threshold. + +For agents claiming multiple specialisms, run one CI job per specialism in parallel; each gets its own mock-server port pair (agent + upstream). Jobs are independent. + +## Iteration loop + +Realistically your first run will not pass both gates. Common shapes and how to debug: + +- **Storyboard `passing` but traffic gate fails on N endpoints** — classic façade. The handlers for those routes either short-circuited or never got exercised by the storyboard's input. +- **Storyboard `partial` with cascade skips** — an early step ([`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products), [`get_signals`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals)) returned a shape-valid response missing fields that the runner extracts state from. Downstream steps skip with `unresolved context variables`. Fix the early step's response shape and most cascade skips clear. +- **Storyboard `failing` on a single step + traffic gate clean** — usually a one-line shape bug (wrong field name, missing required field, status mismatch). The per-step `details` names the field via JSON pointer. +- **Traffic gate empty (0 hits everywhere) + agent appears to start** — agent boot threw a recoverable error after listening on port. Check the agent's stderr. + +Fastest debug loop: use `npx @adcp/sdk@latest storyboard step ` to isolate the failing step. Skips the cascade, runs a single tool call, sub-second feedback. Don't run the full storyboard until the isolated step passes — saves minutes per iteration. + +## Limitations + +Be honest with your team about what these gates do and don't catch: + +### Storyboard limitations + +- **Storyboards under-cover payload variety.** A storyboard step may pass shape with an empty input where a real adapter never gets exercised on the variant that matters. Tracked at [adcontextprotocol/adcp#3785](https://github.com/adcontextprotocol/adcp/issues/3785). + +### Runner / tooling caveats + +- **Storyboard cascade skips silently** when an early step's response is shape-valid but missing fields the runner extracts state from. The error you see is on the *downstream* step, not the early one — investigate "skipped" steps before "failed". Tracked at [adcontextprotocol/adcp#3796](https://github.com/adcontextprotocol/adcp/issues/3796) (runner-side). +- **Mock seed data may not match storyboard fixture inputs.** If you see 404s on `_lookup/`, the storyboard's payloads may reference IDs the mock doesn't seed. Either widen the mock's seed or seed scenario state at runtime via the [compliance test controller](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/by-layer/L4/build-an-agent)** — the language-agnostic guide to building an AdCP agent. +- **[Compliance catalog](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog)** — the full taxonomy of universal / protocol / specialism storyboards. diff --git a/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent.mdx b/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent.mdx new file mode 100644 index 00000000000..bd787e4589c --- /dev/null +++ b/dist/docs/3.3.0-beta.3/building/verification/validate-your-agent.mdx @@ -0,0 +1,302 @@ +--- +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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/verification/compliance-catalog) | + +Declare your `supported_protocols` and `specialisms` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) — the runner picks the matching storyboards automatically. See the [Compliance Catalog](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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 + ✓ canonical format declarations + ✓ 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. + +### Select a compliance cache and test kit + +`--compliance-version` selects a cache bundled with the SDK. For an extracted cache, pass its versioned directory to `--compliance-dir`; `--test-kit` takes a YAML file from the `test-kits/` directory below that same cache root. After extracting the `@adcp/sdk` 14.0.0 npm tarball in the current directory, use its versioned cache root like this: + +```bash +npx @adcp/sdk@14.0.0 storyboard run my-agent \ + --compliance-dir ./package/compliance/cache/3.2.1 \ + --test-kit ./package/compliance/cache/3.2.1/test-kits/acme-outdoor.yaml +``` + + +`@adcp/sdk` 13.0.0 and later infer the compliance line from test-kit metadata and reject mixed or ambiguous `--test-kit`, `--compliance-version`, and `--compliance-dir` selections before contacting the agent. Keep the test kit and storyboard files under the same versioned cache root. + + +The storyboard runner operates in two modes depending on whether your agent implements the optional [compliance test controller](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller): + +| Mode | When | What it tests | +|------|------|---------------| +| **Observational** | No test controller | Response schemas and buyer-initiated flows | +| **Deterministic** | Test controller present | Full lifecycle state machines, error codes, operation gates | + +### Reading `partial` + +`partial` is a coverage result, not automatically a failure. The runner uses it when one or more selected scenarios could not be graded even though no executed assertion failed. The most common cause is intentional: production endpoints MUST NOT expose [`comply_test_controller`](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller), so controller-seeded storyboards skip with a storyboard-level `requirement_unmet` naming `controller`, and controller-forced steps that are not gated skip with `missing_test_controller`. + +Read `steps_not_selected` separately from `steps_skipped`. Not-selected scenarios were outside the suite or run mode you asked for. Skipped scenarios were inside the selected suite, but the runner could not execute them because of an applicability gate, missing test surface, missing tool, or prerequisite. + +Use the summary counters and skip reasons to decide what the result means: + +| Summary | Interpretation | +|---------|----------------| +| `0 failed`, `steps_not_selected > 0`, `steps_skipped = 0` | Expected for the selected run mode, such as sandbox-only testing that excludes live-only probes | +| `0 failed`, only capability-gate `not_applicable` skips | Clean pass for the capability scope the seller declared | +| `0 failed`, `requirement_unmet` (`controller`) or `missing_test_controller` skips | Buyer-visible path passed, but deterministic lifecycle coverage was not graded | +| Any `missing_tool` skip for a declared protocol or specialism | The seller overclaimed or forgot to expose a required tool | +| Any failed step | Not conformant for the declared scope until fixed | + +For production-path sandbox validation, a result like `84 passed, 0 failed, 0 skipped, 80 not selected` is a clean sandbox-only selection result: the excluded probes were never part of that run. A result like `84 passed, 0 failed, 80 skipped` needs the skip breakdown before it means anything. Skips because an optional capability was not claimed are selected-scope skips. Skips with `missing_test_controller` are deterministic coverage gaps: the suite tested the public sandbox path and reported that controller-seeded lifecycle scenarios were not graded. To get full green on those, run against a dev/staging endpoint that exposes the controller, pre-seed the required state and tell the runner to assert seeded-state coverage, or accept and publish the skipped coverage list as an explicit limitation. + +## Validate through Addie + +[Addie](https://agenticadvertising.org) provides interactive testing without any CLI setup. Paste your agent URL in any conversation to get started. + +### Connectivity check + +Ask Addie to check your agent. She'll verify it's online, list its advertised tools, and confirm the transport protocol (MCP or A2A). This is the quickest way to confirm your agent is reachable before running any tests. + +### Storyboard coaching + +Addie runs the same storyboards as the CLI but walks you through each step interactively. When a step fails, she explains what went wrong, shows the expected vs actual response, and suggests specific code changes. This is the fastest way to iterate when you're building. + +### RFP testing + +Share a real RFP or campaign brief with Addie. She'll parse it, call your agent's [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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. For buyer-declared accounts, the runner includes `sandbox: true` in every natural-key account reference. For account-ID namespaces, it first resolves and verifies a sandbox account, then sends `{ "account_id": "..." }` without a sandbox flag. In either model, your agent processes the selected sandbox account without real platform calls or spend. + +Your agent should declare sandbox support in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/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.3.0-beta.3/media-buy/advanced-topics/sandbox) for full implementation details and the natural-key and account-ID paths. + +## Verifying cross-instance state + +The protocol requires that `(brand, account)`-scoped state [survive across agent process instances](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/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.3.0-beta.3/building/by-layer/L3/comply-test-controller)** — Implement deterministic testing for full lifecycle coverage +- **[Task lifecycle](/dist/docs/3.3.0-beta.3/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling +- **[Error handling](/dist/docs/3.3.0-beta.3/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery diff --git a/dist/docs/3.3.0-beta.3/community/joining-slack.mdx b/dist/docs/3.3.0-beta.3/community/joining-slack.mdx new file mode 100644 index 00000000000..9ff01713ead --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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 AgenticAdvertising.org member organizations. 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.3.0-beta.3/community/working-group) — what the community works on and how to participate +- [Membership](/dist/docs/3.3.0-beta.3/aao/users) — join AgenticAdvertising.org for full access (working groups, certification, Slack) diff --git a/dist/docs/3.3.0-beta.3/community/working-group.mdx b/dist/docs/3.3.0-beta.3/community/working-group.mdx new file mode 100644 index 00000000000..26b40e6d903 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/community/working-group.mdx @@ -0,0 +1,48 @@ +--- +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](/dist/docs/3.3.0-beta.3/community/joining-slack)** — the guide includes the public invite and the direct-invite recovery path for restricted domains. + +## 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! + +## Governance + +The WG's operational procedures — quorum, voting thresholds, recusal policy, and escalation path — are published in the [Working group charter](/dist/docs/3.3.0-beta.3/governance/working-group-charter). diff --git a/dist/docs/3.3.0-beta.3/contributing/creative33-sandbox.md b/dist/docs/3.3.0-beta.3/contributing/creative33-sandbox.md new file mode 100644 index 00000000000..00c2e94ac19 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/contributing/creative33-sandbox.md @@ -0,0 +1,98 @@ +--- +title: Creative 3.3 beta sandbox +description: "Exercise AdCP 3.3 beta creative workflows with the published SDK, isolated sandbox accounts, draft previews, and explicit delivery simulations." +"og:title": "AdCP — creative 3.3 beta sandbox" +--- + +# Creative 3.3 beta sandbox + +This development lab serves the frozen AdCP `3.3.0-beta.1` contract using the +published TypeScript SDK `17.0.0-beta.8`. Its MCP endpoint is: + +`https://test-agent.adcontextprotocol.org/beta/3.3/creative/mcp` + +The wire selector is `3.3-beta.1`. The endpoint defaults to that beta and +rejects stable or other prerelease selectors. Capability discovery lists only +this release and the implemented creative fixture contracts. All operations +finish synchronously. + +## Connect + +Use the documented public test-agent bearer credential or an accepted test-agent +API credential. Cookie authentication does not authorize the endpoint. Each +credential resolves its own sandbox account, available through `list_accounts`; +`account: { account_id: "creative33_sandbox" }` is an alias for that caller's +account. People using the same public credential share its sandbox. + +Production installations enable the route with +`ENABLE_ADCP_3_3_CREATIVE_ROUTE=true`; the public training-agent deployment opts +in through `fly.toml`. Test and development processes enable it by default. +The beta library, revision bindings, replay keys, and controller fixtures are +separate from the existing training-agent tenants. Production uses the existing +Postgres state and idempotency tables with dedicated namespaces. + +## Exercise the buyer workflow + +1. Call `get_adcp_capabilities` and choose an entry from + `creative.supported_formats`. The catalog declares its actual input slots, + delivery types, and sizes. +2. Invoke `build_creative` through the SDK's typed + `agent.executeTask('build_creative', request)` API. The responsive ZIP and + display-tag builders return deterministic first-party fixtures. The + `beta_video_15s` builder consumes a typed `video_brief` manifest whose + sequential segments total 15 seconds and returns a playable 640×360 MP4 + fixture. This is a fixed sandbox sample, not generated campaign footage. +3. Upload the resulting manifest with `sync_creatives`, an explicit + `revision_id`, and a fresh `idempotency_key`. Read it back with + `list_creatives` to inspect the complete source asset, including its + selectable sizes. `validate_input` checks inline manifests against the + advertised capability contracts without storing them. +4. Preview a stored creative with `request_type: "single"`. For responsive + assets, select a supported canvas through an input's `AD_WIDTH` and + `AD_HEIGHT` macros. The sandbox returns `quality_used: "draft"` and an + escaped visualization of the selected canvas. It does not fetch or execute + uploaded ZIP archives, HTML, or third-party tags. Consumers must still use + the sandbox/CSP handling described in the preview documentation. +5. Call `get_creative_delivery` before simulating execution: the creative has + zero delivery and no served variants. To exercise execution readback, use + `comply_test_controller` with `seed_media_buy`. Its fixture contains one + package with a matching `format_kind`, concrete `params.width` and + `params.height`, and one `creative_assignments` entry. Then call the + existing `simulate_delivery` scenario with that `media_buy_id` and metrics. + Delivery readback now identifies that simulated buy, the selected fixed + canvas, and the source revision. The library's responsive source stays + intact. These controller-created records are test evidence, not physical + campaign delivery or proof that an arbitrary external tag resizes. + +The two display canvases are 300×250 and 728×90. Unsupported selections fail. +Raw inline HTML has a separate discovered, digest-pinned custom contract for +library storage and draft preview; it is not accepted as an HTML5 ZIP or a +third-party display tag and cannot be assigned simulated delivery. Source +briefs likewise must be built into finished video before delivery simulation. + +The sandbox intentionally bounds library and execution state. It supports the +advertised fixtures and returns explicit errors for unsupported operations, +including asynchronous tasks, push notifications, and exact variant replay. +Requests are limited to 256 KB and individual manifests to 64 KB. Strict sync +batches apply atomically; `validation_mode: "lenient"` opts into per-item +acceptance. Duplicate JSON members, invalid Unicode scalars, and non-finite +numbers are rejected before source comparison. Hosts embedding the router must +capture the original JSON body in their parser's `verify` callback. + +For cleanup, the controller's `force_creative_purge` scenario accepts a specific +`creative_id` and `purge_kind: "hard"`. It removes that caller's source, +associated simulation fixtures, and revision bindings. Reusing an ID afterward +starts a new creative incarnation, as required by the frozen hard-purge +contract. This lab does not support soft tombstones or retention commitments. + +## Repeat the checks + +Run `npm run test:creative33-sandbox` from the repository root. The suite starts +the actual HTTP endpoint and uses the published SDK for discovery, build, +upload, readback, preview, and controlled delivery simulation. It checks both +display sizes, typed brief consumption, playable video duration, source field +preservation, immutable revisions, version pins, replay behavior, and caller +isolation. No mutable source schema bundle is substituted for the beta. + +The required Build Check workflow runs this suite explicitly. The same files +also participate in the server unit test shards. diff --git a/dist/docs/3.3.0-beta.3/contributing/organization-deletion-containment.md b/dist/docs/3.3.0-beta.3/contributing/organization-deletion-containment.md new file mode 100644 index 00000000000..bcd56ab9db0 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/contributing/organization-deletion-containment.md @@ -0,0 +1,289 @@ +--- +description: Why direct organization deletion is unavailable, which routes are contained, and the durable lifecycle requirements for re-enabling deletion. +"og:title": AdCP organization deletion containment +--- + +# Organization deletion containment (#6827) + +Self-service `DELETE /api/organizations/:orgId` and administrative force-delete +`DELETE /api/admin/accounts/:orgId` are temporarily unavailable, and so is +organization merge, which deleted an organization as its final step. These are +containment slices, not closure of #6827 or a safe organization deletion service. + +Requests reaching either delete handler receive HTTP **503** with the stable body +below. Organization merge is contained too, with its own code and behind +administrative authentication — see the merge section further down. + +```json +{ + "error": "organization_deletion_unavailable", + "message": "Organization deletion is temporarily unavailable." +} +``` + +The response is unconditional and contains no organization-specific information. +It runs before route authentication, which can hydrate local identity and cache +state, and before authorization, subscription reads, WorkOS/Stripe calls, local +writes, cascades, deletion audit records, cache invalidation or notifications. +The existing server-only observer exclusion prevents post-response canonical or +exact-credential WorkOS reads, including for an already populated principal. +There is no bypass for an exact owner/admin, platform administrator, static admin +key, tenant API key, development session, credential grant, matching email/domain, +subscription status, confirmation text, `force` parameter, or linked canonical +sibling. Unknown IDs receive the same response. Repeated calls perform no work; +there is no operation ID or retry schedule because no operation was accepted. + +Everything in this section describes the two DELETE routes. The contained merge +route runs *after* `requireGlobalAdmin`, so none of the anonymous-reachability +properties below apply to it. + +Global parsing, CSRF protection and ordinary request metrics remain in place. +A parser or CSRF rejection may precede the 503, and the global JSON body parser +still accepts its usual limit for these paths before the route is matched — both +were already reachable unauthenticated on the identical path. Because 503 is a +server-error status, `requestMetrics` records every contained request as a failed +`/api` request, so an unauthenticated caller can raise the 5xx rate for these two +paths at no cost. No page results: the only Slack alerting on this path is the +10-second slow-response gate and this handler is constant-time. An external +error-budget monitor keyed on 5xx should exclude these two routes. Successful authentication is not +needed to learn that this operation is unavailable; no data or authority is +returned. Organization reads and unrelated membership/domain routes retain their +existing behavior and authorization. This change does not establish their +broader security acceptance or restore any previously contained operation. + +## Two deliberate divergences from neighbouring handlers + +**Returning before `requireAuth`/`requireAdmin`.** The repository's precedent for +a switched-off admin endpoint keeps both guards and returns 410 — see the removed +payment-link and invoice routes in `server/src/routes/admin/prospects.ts`. Those +are *permanently* removed endpoints where running authentication first costs +nothing. This containment diverges because `requireAuth` itself performs provider +and database work: WorkOS API-key validation, sealed-session loading and platform +ban lookups. Those are exactly the side effects this slice must not perform, so +the response has to precede them. The contained handler is strictly cheaper than +the middleware it replaced, and the mounted tests assert that neither API-key +validation nor session loading fires. + +**A machine-stable snake_case error code.** Neighbouring 503s in these two files +use a human sentence in `error`. `organization_deletion_unavailable` is a stable +identifier on purpose, so a caller can branch on containment without string +matching, and it is consistent with the dozens of other snake_case error codes +already used across `server/src/routes/`. The human-readable text lives in +`message`. + +## Why deletion remains unavailable + +The parent implementation used canonical `req.user.id` for the self-service +owner lookup. Linked credential A could therefore authorize using sibling B's +owner membership. Administrative deletion depended on the existing admin policy, +which also includes canonical identity and static administration. Exact identity +checks alone would not repair the deletion lifecycle. + +Both handlers read the organization and payment history, obtained subscription +information (potentially reading Stripe), checked confirmation, and then: + +1. Inserted `organization_deleted` into the local audit table. +2. Attempted WorkOS organization deletion (admin skipped it if WorkOS was absent). +3. Swallowed a WorkOS error and continued with local organization deletion and + its foreign-key effects. +4. Reported success if the local delete completed. + +Those steps were not one transaction. Provider failure could leave a live +provider organization with local state deleted. A timeout could complete late; +a local failure after provider success could leave the opposite split state. +The early audit asserted deletion before either outcome was known. Subscription +creation could race the preliminary billing reads. The containment removes this +sequence; it does not attempt to recreate an organization as compensation. + +The parent behavior above was demonstrated during investigation by running the +parent commit's handlers against a disposable local PostgreSQL database with a +WorkOS double. That demonstration is deliberately **not** committed: its +assertions require the deleting handlers, so it cannot live in this tree and +cannot be re-run from it. Treat the list below as investigation findings, not as +a reproducible gate. The last item is the one a reader can verify statically from +the schema. What the demonstration established about the parent tree: + +- A linked credential with no membership of its own completed a self-service + deletion using its canonical sibling's owner membership. WorkOS deletion was + called, an audit row was written, and the organization row was removed. +- A WorkOS rejection was swallowed: the local organization row was still + deleted and the caller still received `success: true`. +- The `organization_deleted` audit row survived that provider failure, so a + durable record claimed a deletion that had only half happened. +- A platform administrator completed the same sequence through the + administrative entry point. +- Both entry points returned the organization name in a pre-deletion + confirmation error before *exact-credential* authority had been established. + Authority as such was checked: self-service required an `owner` role and the + administrative route required `requireAuth` plus `requireAdmin`. The defect is + that self-service authority was borrowable from a canonical sibling, not that + it was absent. +- Local `organization_memberships` rows were not removed with the organization, + leaving dangling membership state for an organization that no longer existed. + This one is verifiable from the schema: `organization_memberships` + (`server/src/db/migrations/055_organization_memberships.sql`) declares no + foreign key to `organizations` or `users`, and no later migration adds one. + Any future lifecycle design must inventory this edge rather than assume the + foreign-key graph cleans it up. + +## Organization merge: contained by the follow-up + +Organization merge was the remaining organization-deletion writer. It is now +contained too, on the same terms and with the same stable shape. + +Merge deleted the secondary organization: `mergeOrganizations` +(`server/src/db/org-merge-db.ts`) removed the local organization row inside its +transaction, committed, and only then attempted +`workos.organizations.deleteOrganization`, downgrading a provider failure to a +warning while still reporting success. The Addie path attempted the provider +delete twice — once inside `mergeOrganizations` and again at step 4 of the tool +handler — and interleaved WorkOS membership creation in the primary organization +between the local commit and that second attempt, so its unknown-outcome surface +was strictly larger than the HTTP route's. + +Both entry points now refuse before any merge-specific effect: + +- `POST /api/admin/cleanup/merge` (`server/src/routes/admin/cleanup.ts`) returns + HTTP **503** with the stable body below. Administrative gating is deliberately + **unchanged** — `requireGlobalAdmin` still authenticates and authorizes, unlike + the contained deletion routes, because nothing here needs to run before + authentication. The refusal precedes body validation and every merge-specific + effect. +- The Addie `merge_organizations` tool refuses execution before it reads provider + memberships, and no longer acquires a WorkOS client at all on that path — the + client is now constructed inside the read-only preview branch rather than at + handler entry. The refusal names the stable code, points at preview, and tells + the model not to improvise a manual database or WorkOS workaround. + +```json +{ + "error": "organization_merge_unavailable", + "message": "Organization merge is temporarily unavailable." +} +``` + +`mergeOrganizations` itself refuses at the shared service boundary, throwing +`OrganizationMergeUnavailableError` with the same stable code before `getPool()`, +before `pool.connect()` and before `BEGIN`. That is defense in depth: an +overlooked caller, a new caller, or a script that bypasses both entry points +still cannot execute the sequence. The previous implementation was removed rather +than left unreachable behind the guard, because that sequence *is* the hazard — +re-enabling merge has to be a new reviewed implementation meeting the acceptance +requirements below, not the deletion of a guard. + +After this follow-up there are **no production call sites of +`workos.organizations.deleteOrganization` left in `server/src`**, so no server +route, tool or service calls the provider's organization-delete operation. The +claim is about SDK call sites; it is not a general statement about every request +this server can construct. + +### Operator bypasses closed alongside the route and tool + +A containment that only covers routes and services is one an operator can walk +around. Two checked-in surfaces handed them the prohibited operation: + +- `scripts/incidents/2026-05-cleanup-duplicate-prospect-stubs.ts` deleted + duplicate organization rows with direct SQL, and its own header reasoned from + the absence of an admin delete endpoint that a script was the sanctioned + route. Its destructive path is removed: it no longer imports a database + client, issues no writes, and `--execute` hard-refuses before any other + statement runs. The read-only analysis and report are preserved. +- The `unique-org-per-email-domain` invariant emitted a ready-to-paste raw SQL + delete with the organization ID already filled in, and pointed at a merge + endpoint that does not exist. Its `remediation_hint` now routes to read-only + `preview-merge` inspection plus escalation, and names both containment codes. + +`server/tests/unit/organization-delete-statement-guard.test.ts` is the static +guard for this: it scans the JavaScript, TypeScript and SQL files under +`server/src`, `server/scripts` and `scripts` for a raw organization delete and +fails on anything outside a small allowlist that records +why each remaining occurrence is not operator-reachable. Re-introducing a bypass +now requires a deliberate, reviewed edit to that allowlist. + +### Remaining honest limitations + +- **Merge preview stays available and is genuinely read-only.** + `previewMerge` and `GET /api/admin/cleanup/preview-merge` issue only SELECT and + COUNT statements. Preview still projects what a merge *would* move, including + that the secondary organization would be deleted from WorkOS. The tool's + WorkOS section uses conditional wording; the existing Stripe and data-movement + warnings are unchanged. The tool output states that execution will refuse. +- **Three code paths still contain a raw organization delete, none of them a + deletion service** — all three are recorded in the static guard's allowlist. + `OrganizationDatabase.deleteOrganization` + (`server/src/db/organization-db.ts`) has no callers and predates this work. + Prospect creation (`server/src/services/prospect.ts`) removes the row it + inserted moments earlier in the same call when the domain link conflicts; it + never removes a pre-existing organization, but it does leave the WorkOS + organization it had just created behind. That orphan is a pre-existing + creation-rollback gap, not an organization-deletion path, and is out of scope. + `server/scripts/setup-sandbox.ts` seeds and tears down a local sandbox; it is + bounded to `org_aao_sandbox_*` IDs and refuses to run unless + `STRIPE_SECRET_KEY` is `sk_test_*`, so it never touches production data. It is + documented and allowlisted rather than changed. +- **The `merge_organizations` tool's declared description and usage hints are + unchanged.** They still read "Destructive, cannot be undone" and "Preview + first, then execute with `preview=false`", which is now misleading. Changing + them is a one-line edit, but `scripts/addie-tool-surface-budget.json` pins a + reviewed profile-contract hash over every declared tool profile — it hashes + the whole profile set, including each profile's wire-schema digest, so a + one-word description edit invalidates it — and re-blessing that hash is its own + review decision rather than something to fold into a containment commit. The + runtime refusal is the authoritative guard in the meantime: an execution + attempt refuses without effect. Follow-up: update both strings, regenerate with + `npm run build:addie-tools`, and re-bless the contract hash under its own + review. +- **Admin UI copy still offers merge.** The merge controls in + `admin-data-cleanup.html`, `admin-domain-health.html` and + `admin-account-detail.html` surface the unavailable message correctly (each + throws on a non-OK response and renders `message`), but the surrounding copy + still promises a merge. Same known follow-up as the deletion modal below. +- **Not in scope here:** member merge and member migration, domain reassignment, + and every read-only inspection surface. User and identity merge + (`server/src/db/user-merge-db.ts`) is separately contained under + `identity_mutation_disabled`. None of them deletes an organization. + +## Operator handling while deletion is unavailable + +There is no operator bypass, break-glass credential or hidden flag: the response +is unconditional. A member's legitimate workspace-deletion request — including a +data-erasure request with a compliance deadline — is therefore queued until +deletion is re-enabled, and must be escalated to the engineering owner of #6827 +rather than worked around. Direct `psql` deletion and organization merge are both +unsafe substitutes: they reproduce exactly the split provider/local state and the +dangling membership rows described above. + +The administrative UI is unchanged by this slice. The danger-zone modal in +`server/public/admin-account-detail.html` still offers a Delete workspace button +and still says workspaces with payment history or an active subscription cannot +be deleted, which is now misleading — no workspace can be deleted. The button +degrades correctly (the operator sees the unavailable message after confirming), +but the copy and the disabled state are a known follow-up. + +## Acceptance requirements before enabling deletion + +A separately reviewed design and implementation must satisfy every row below on +both entry points and all other participating writers. A role check, feature +flag, migration alone, best-effort rollback, or an in-memory lock is insufficient. + +| Boundary / failure | Required durable behavior and acceptance evidence | +| --- | --- | +| Exact authority and target | Bind the authenticated credential, explicit organization, permitted action, membership/grant policy, provider organization ID and authorization epoch to the operation. Reject conflicting/malformed selectors and canonical sibling substitution. Recheck authority at defined irreversible boundaries. Any future admin authority policy requires explicit review; existing static/platform status is not an exception to this containment. | +| Durable operation journal | Persist a unique operation ID, caller/idempotency binding, exact actor and target, immutable preconditions and provenance, provider request correlation, state/version, attempts and known/unknown outcomes before any external mutation. Journal/tombstone retention must survive organization deletion, process death and database recovery. Enumerate every allowed transition and who can perform it. | +| Prepare failure | Journal/precondition/audit-intent failure causes zero provider or destructive local work. Prove connection errors, transaction rollback, epoch changes and billing conflicts. No success or accepted claim without the corresponding durable record. | +| Provider rejection or absence | Classify a definite rejection separately from an unknown outcome. Preserve local organization, memberships, grants and provenance. A missing provider client must never authorize local-only deletion. Record an honest durable outcome and permitted recovery action. | +| Timeout, disconnect and late completion | Persist an unknown/reconciliation-required state across timeout, process crash and restart. Correlate late provider completion, webhook and polling evidence to the same exact operation. Prove completion before/after retry, duplicate and out-of-order events, and no second delete or premature success. A transport timeout is not proof of provider rollback. | +| Local failure after provider success | Preserve the provider outcome durably and expose reconciliation-required status. Inject failure at every local write/commit boundary. Specify exact forward reconciliation or a provider-supported compensation that restores the same IDs, memberships, roles, seats, grants, provenance and dependent resources. If exact restoration is impossible, document that limit and require a safe recovery workflow; never invent replacement identities or silently recreate approximate state. | +| Mandatory audit | Commit truthful intent/outcome records with the corresponding journal/local state transition; audit must survive cascades and identify the exact credential, target and operation. Audit failure blocks success. Prove failures before provider dispatch and after provider completion, without falsely claiming deletion or losing the provider outcome. | +| Replay and recovery | Same key plus same actor/target/payload returns the same durable operation/outcome; conflicting reuse fails. Prove retries after every crash boundary, concurrent duplicate requests, worker lease expiry and restarts. Neither replay nor a stale session may create new authority or repeat an irreversible provider effect. | +| Concurrent subscription creation | Coordinate deletion with checkout, invoice, admin, webhook, sync and subscription writers using a shared durable fence/version protocol. Prove both orderings: subscription wins and deletion refuses without provider effects; deletion reserves the organization and subsequent subscription creation cannot attach or complete unnoticed. Include an in-flight remote subscription that completes after local checks. Locking only the deletion route is insufficient. | +| Concurrent membership/identity changes | Coordinate with membership, grants, owner changes, link/unlink, epoch and ban writers. Preserve historical provenance and reject stale authority without borrowing a sibling. Document unavoidable provider compare-and-swap limits and reconcile them honestly. | +| Local dependent state, cache and notifications | Inventory every cascade/set-null/restrict edge and retained audit/journal record. Make local transitions atomic and bind cache invalidation/notifications to committed outcomes with durable delivery/deduplication. Prove no premature success notifications, missed invalidation or cross-organization effects. | +| Independent qualification | Mounted cookie/JWT/API-key, selector, linked A/B and exact role tests; real disposable PostgreSQL failures/races; provider timeout/late completion tests; restart/replay tests; mandatory audit tests; independent review bound to the exact candidate tree; required repository checks. Controlled doubles alone do not prove live provider semantics. | + +No lifecycle journal, migration, deletion worker, compensation or reconciliation +implementation is introduced by this containment, and none is introduced by the +merge follow-up. Historic authority repair and other #6827 surfaces remain +separate work. Enabling organization deletion — whether directly or through +merge — requires a new reviewed change after the complete lifecycle contract +above is accepted. diff --git a/dist/docs/3.3.0-beta.3/contributing/storyboard-authoring.md b/dist/docs/3.3.0-beta.3/contributing/storyboard-authoring.md new file mode 100644 index 00000000000..e39ee6a23b3 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/contributing/storyboard-authoring.md @@ -0,0 +1,433 @@ +--- +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/`, the canonical authored source for the compliance bundle. Do not add generated cache artifacts such as `domains/` or `index.json` there; `scripts/build-compliance.cjs` creates them in `dist/compliance/latest/` during development and stamps `dist/compliance/{version}/` on release. + +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`, or proposal IDs inside `refinements`/`declines`), 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 (`get_adcp_capabilities`, plus deprecated `list_creative_formats` compatibility tests), global catalog reads (`get_brand_identity`, `get_rights`, `update_rights`), and the `comply_test_controller` sandbox primitive. + +### Why ID-scoped tasks are exempt + +`check_governance`, `report_plan_outcome`, `acquire_rights`, `log_event`, `calibrate_content`, `validate_content_delivery`, `validate_property_delivery`, `refine_proposals`, and `decline_proposals` all require one or more globally-unique IDs that were previously provisioned with brand context. At the spec level, a real seller resolves each ID → tenant via its own lookup; the envelope doesn't need to repeat the identity. + +Most legacy ID-scoped storyboards still carry envelope identity because the training agent's `sessionKeyFromArgs` uses it for sandbox routing. The split proposal-lifecycle schemas deliberately forbid `account` on `refine_proposals` and `decline_proposals`; for those tools, the training agent reverse-resolves the proposal IDs to the originating account scope. + +The remaining envelope identity 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.3.0-beta.3/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. + +## 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 qualifying product, a creative already in `approved` status, a plan the governance flow can reference) have three ways to obtain it: **declarative `fixtures:` at the storyboard root** for state that exists *before* the test runs, optional **`fixture_resolution:` rules** when a seller-owned entity may satisfy that state, and **step `context_outputs:` captures** for IDs *generated during* the run. + +### When to use which + +| Fixture origin | Pattern | Authored as | +|---|---|---| +| Exists before the storyboard and must be injected literally | `fixtures:` at storyboard root | Declarative block; seed-only compatibility default | +| Exists in the seller's sandbox catalog | `fixtures:` plus explicit `fixture_resolution:` | Runner seeds or discovers according to the declared strategy order and binds the seller-issued ID | +| 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 +requires: + - controller +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_kind: "video_hosted" + +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. + +**A storyboard with `prerequisites.controller_seeding: true` MUST also declare `requires: [controller]`.** Without it, a seller that has no test controller is graded `missing_test_controller` on every seeded step instead of one storyboard-level `requirement_unmet` skip (capability gates still evaluate first, so a seller that never claimed the capability gets `not_applicable`). `tests/lint-storyboard-controller-requires.test.cjs` enforces this. + +When a vendor-metric storyboard needs a deterministic external `measurement.metrics[]` snapshot, add an explicit `comply_test_controller` step with `scenario: seed_measurement_catalog`. Use `measurement_catalogs[]` inside a product fixture only as a compatibility fallback when the same storyboard also needs to carry the seller's product-level capability fields. + +See the full list of seed scenarios and their params in [Compliance test controller — Scenarios](/dist/docs/3.3.0-beta.3/building/by-layer/L3/comply-test-controller#scenarios). + +### Pattern B — seller-catalog fixtures via `fixture_resolution:` + +Keep the seed exemplar in `fixtures:` and add runner-only matching rules in a +separate root block. An older runner continues to seed the literal. A 3.2 runner +tries the declared strategies in order and may bind the handle to a seller ID. + +```yaml +fixtures: + products: + - product_id: "test-product" + delivery_type: "non_guaranteed" + channels: ["display"] + +fixture_resolution: + products: + - handle: "test-product" + strategies: [seed, discover] + match: + - path: /delivery_type + operator: equals + value: non_guaranteed + - path: /channels + operator: contains_all + value: [display] +``` + +Do not add `discover` by copying every fixture field into equality clauses. +Declare only properties material to the storyboard's assertions. Presentation +copy, sample prices, and other illustrative values should not become accidental +eligibility gates. Conversely, a currency-filter storyboard must keep currency +as a requirement because changing it changes the behavior under test. + +Discovery rules are explicit because fixture objects are not generic subset +predicates. The source lint rejects malformed strategies, dangling handles, +unknown operators, and ambiguous product-scoped pricing handles. See the +normative `fixture_resolution` section in `storyboard-schema.yaml` for all +operators and grading behavior. + +### Pattern C — 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. + +### Every `$context.` must have a writer + +`injectContext()` substitutes a `$context.` token only when the key is +already in the run context. When it isn't, the token survives into the request +and the runner refuses to send it: the step comes back `skipped` with +`skip_reason: prerequisite_failed` and a failed `unresolved_substitution` +validation. The storyboard can never pass — but it reports as a *skip*, which +is also what legitimate capability gating produces, so nobody notices. + +That is how #7797 shipped: the 3.0.x governance scenarios registered the +governance agent with `url: "$context.governance_agent_url"` and never declared +the key, so `sync_governance` could only ever skip. On the 3.0-compat tenants +that step already skips on `missing_tool`, which hid the difference entirely, +and the defect rode every released 3.0 bundle up to 3.0.26 before #7801 added +the default. + +So every key a storyboard reads needs a writer that runs first. "Reads" is +wider than `sample_request`: the runner also substitutes `validations[].value` +and `validations[].allowed_values[]`, resolves `validations[].context_key` +from the same snapshot, and substitutes a webhook step's `filter`. Those +assertion surfaces are resolved against the context as it stood *before* the +step, and nothing guards them — an unresolved `value` quietly becomes the +comparand (the check grades the response against the literal string +`"$context."`), an absent `context_key` makes the check *pass* with a +`context_key_absent` observation, and a token left in a `filter` yields a +selector no delivery can match. A step's own `context_outputs` land after its +validations run, so a step cannot assert against a key it captures itself +(#8328). + +The writers: + +- **test topology** (an agent URL, a fixed sandbox id) — declare it in the + storyboard's root `context:` block, which seeds the run context before step + one. A `--` override on the runner replaces the default; it does not + substitute for it; +- **flow-derived values** — capture them with `context_outputs` on an *earlier* + step. A step cannot read the key its own `context_outputs` writes; +- **convention-extracted values** (for example `media_buy_id` after + `create_media_buy`) — the runner writes these from the response without any + YAML declaration. The lint models them per task, so the key only counts as + written once a step that produces it has run. + +`scripts/lint-storyboard-context-resolution.cjs` replays the runner's +substitution over requests (`context_placeholder_unresolved`) and over every +assertion comparand (`context_assertion_unresolved`), and fails on anything +left unresolved. It runs as a hard gate in +`npm run build:compliance` — a bundle must not ship the defect, because +`dist/**` release artifacts are immutable — and +`npm run test:storyboard-context-resolution` re-checks the source tree plus +every bundled release. + +Five keys are stricter than "declare it somewhere": `governance_agent_url`, +`seller_agent_url`, `signal_agent_url`, `brand_agent_url` and +`creative_agent_url` are seeded by hosted grading from server config, so they +may only come from the root `context:` block. Capturing one from a response +would let the agent under test choose the governance agent it is told to trust. + +This applies to buyer-track storyboards too. Their `sample_request` is +nominally the reference shape the buyer agent's own call is graded against, but +the SDK has no `interaction_model` awareness and will hand them to the ordinary +runner, so a token with no writer is just as broken there. + +### 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 the same context JSON value" +``` + +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.3.0-beta.3/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.3.0-beta.3/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. an operation may return immediate success OR `pending_review` depending on seller policy. 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. + +Use this shape for any spec `MAY` / `any_of` where observable outcomes differ across branches. Do not use it for `create_media_buy.start_time` in the past; that case is now reject-only with `INVALID_REQUEST`. + +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 the same `context:` JSON value 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`](https://github.com/adcontextprotocol/adcp/blob/main/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.3.0-beta.3/contributing/testable-examples-demo.md b/dist/docs/3.3.0-beta.3/contributing/testable-examples-demo.md new file mode 100644 index 00000000000..1360e805252 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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 + +### Discover Creative Capabilities + +```javascript +import { testAgent } from '@adcp/sdk/testing'; + +const result = await testAgent.getAdcpCapabilities({}); + +console.log(`✓ Found ${result.data?.creative?.supported_formats?.length || 0} creative capabilities`); +``` + +## Python Example + +### Discover Creative Capabilities + +```python +import asyncio +from adcp.testing import test_agent + +async def list_formats(): + result = await test_agent.simple.get_adcp_capabilities() + print(f"✓ Found {len(result.creative.supported_formats)} creative capabilities") + +asyncio.run(list_formats()) +``` + +## CLI Example + +### Using uvx (Python CLI) + +```bash requires-env=ADCP_AUTH_TOKEN +uvx adcp \ + https://test-agent.adcontextprotocol.org/creative/mcp \ + get_adcp_capabilities \ + '{}' \ + --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/sdk`, `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.3.0-beta.3/contributing/testable-snippets.md b/dist/docs/3.3.0-beta.3/contributing/testable-snippets.md new file mode 100644 index 00000000000..5980ad75502 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/contributing/testable-snippets.md @@ -0,0 +1,419 @@ +--- +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/sdk/testing'; + +const products = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442068', + buying_mode: 'brief', + brief: 'Premium athletic footwear with innovative cushioning', + brand: { + domain: 'nike.com' + } +}); + +console.log(`Found ${products.products.length} products`); +``` +```` + +### Snippet Metadata + +Use snippet metadata for examples that need local preconditions: + +````markdown +```bash requires-env=ADCP_AUTH_TOKEN +uvx adcp https://test-agent.adcontextprotocol.org/sales/mcp get_products '{"idempotency_key":"550e8400-e29b-41d4-a716-446655442069","buying_mode":"brief","brief":"Premium CTV inventory"}' --auth $ADCP_AUTH_TOKEN +``` + +```javascript integration=true +// Runs only when snippet integration tests are enabled. +``` +```` + +`requires-env=NAME` skips the snippet when the named environment variable is not set. `integration=true` skips the snippet in the default local run; execute those examples with `node tests/snippet-validation.test.cjs --integration` or `SNIPPET_INTEGRATION=true`. + +### Using Test Helpers + +For simpler examples, use the built-in test helpers from client libraries: + +**JavaScript:** +```javascript +import { testAgent, testAgentNoAuth } from '@adcp/sdk/testing'; + +// Authenticated access +const fullCatalog = await testAgent.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442056', + buying_mode: 'brief', + brief: 'Premium CTV inventory' +}); + +// Unauthenticated access +const publicCatalog = await testAgentNoAuth.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442057', + buying_mode: 'brief', + 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( + idempotency_key='550e8400-e29b-41d4-a716-446655442058', + buying_mode='brief', + brief='Premium CTV inventory' + ) + + # Unauthenticated access + public_catalog = await test_agent_no_auth.simple.get_products( + idempotency_key='550e8400-e29b-41d4-a716-446655442059', + buying_mode='brief', + 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/sdk'; + +const client = new AdcpClient({ + agentUrl: 'https://test-agent.adcontextprotocol.org/sales/mcp', + protocol: 'mcp', + bearerToken: 'sk_your_api_key_here' +}); + +const products = await client.getProducts({ + idempotency_key: '550e8400-e29b-41d4-a716-446655442060', + buying_mode: 'brief', + 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/sdk'; + +const client = new AdcpClient({ + agentUrl: 'https://test-agent.adcontextprotocol.org/sales/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.cjs +``` + +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 + +Integration-only snippets are skipped unless `--integration` is passed or `SNIPPET_INTEGRATION=true` is set. + +### Coverage Reporting + +Use the docs example coverage report to see where schema-backed JSON examples +and runnable snippets are concentrated: + +```bash test=false +npm run docs:example-coverage +``` + +The report scans `docs/` and shows: + +- JSON blocks that include `$schema` and are therefore covered by `npm run test:json-schema` +- complete JSON blocks without `$schema` +- runnable JavaScript, TypeScript, Python, and shell snippets that are covered by `npm run test:snippets` +- top files with the largest unvalidated JSON or untested runnable-snippet gaps + +For CI dashboards or saved baselines, emit machine-readable output: + +```bash test=false +npm run --silent docs:example-coverage -- --json +``` + +For GitHub job summaries, emit Markdown: + +```bash test=false +npm run --silent docs:example-coverage -- --markdown +``` + +Schema validation accepts extension fields where the wire protocol is +extensible. To audit schema-backed docs examples for unknown public-looking +fields, run: + +```bash test=false +npm run docs:json-field-audit +``` + +The field audit is advisory by default. Use `--check` only when intentionally +ratcheting against `scripts/docs-json-field-audit-baseline.json`: + +```bash test=false +npm run docs:json-field-audit -- --check +``` + +When you intentionally clean up findings, refresh the baseline in the same +change: + +```bash test=false +npm run docs:json-field-audit -- --update-baseline +``` + +### 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/sdk` 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/sdk' +``` + +This indicates the `@adcp/sdk` 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 the [Slack community](/dist/docs/3.3.0-beta.3/community/joining-slack) diff --git a/dist/docs/3.3.0-beta.3/contributing/x-entity-annotation.md b/dist/docs/3.3.0-beta.3/contributing/x-entity-annotation.md new file mode 100644 index 00000000000..bd96d2ce324 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/contributing/x-entity-annotation.md @@ -0,0 +1,150 @@ +--- +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`, `media_buy_issue`, `business_calendar`, `package`, `product`, `proposal`, `product_pricing_option`, `spot_airing` | +| Creative | `creative`, `creative_revision`, `creative_seller_revision`, `creative_representation`, `macro_declaration`, `tracker_execution_selector`, `creative_locale_variant`, `creative_format`, `creative_evaluation`, `build_variant`, `served_variant` | +| Data & targeting | `audience`, `audience_evidence`, `audience_evidence_snapshot`, `signal`, `signal_activation_id`, `demographic_interval_id`, `event_source` | +| Lists & catalogs | `collection_list`, `property_list`, `catalog`, `catalog_generation`, `catalog_item`, `property`, `collection`, `installment` | +| Plans & governance | `media_plan`, `governance_plan`, `governance_budget_period`, `governance_registry_policy`, `governance_inline_policy`, `governance_check`, `governance_delivery_statement`, `governance_delivery_observation`, `governance_outcome`, `governance_adjustment`, `governance_adjustment_evidence`, `seller_adjustment`, `content_standards`, `task`, `attestation_credential` | +| Vendor services | `vendor_pricing_option`, `vendor_metric` | +| 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. + +**Outcome vs. adjustment:** `governance_outcome` identifies the governance agent's settled commitment record; `governance_adjustment` identifies the governance agent's append-only adjustment record; `seller_adjustment` identifies the seller's source record and is scoped to that authenticated seller. + +**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.3.0-beta.3/creative/accessibility.mdx b/dist/docs/3.3.0-beta.3/creative/accessibility.mdx new file mode 100644 index 00000000000..63179d86cd0 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/accessibility.mdx @@ -0,0 +1,243 @@ +--- +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_kind": "image", + "params": { + "width": 300, + "height": 250, + "accessibility": { "wcag_level": "AA", "requires_accessible_assets": true }, + "slots": [ + { "asset_group_id": "image_main", "asset_type": "image", "required": true }, + { "asset_group_id": "headline", "asset_type": "text", "required": true } + ] + } +} +``` + +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_kind": "video_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_kind": "html5", + "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. + +## Structured Rejection Details + +When a creative agent or seller performs an accessibility check and rejects a +creative, it uses the existing `CREATIVE_REJECTED` code with +`recovery: "correctable"`. For backward compatibility, the top-level `field` +identifies the first failing asset in JSONPath-lite form. The optional +`error.details` object may conform to the experimental +[`accessibility-violation.json`](https://adcontextprotocol.org/schemas/3.2.1/error-details/accessibility-violation.json) +shape and batch every failure in `violations[]`: + +```json +{ + "code": "CREATIVE_REJECTED", + "message": "Creative fails the required accessibility level", + "field": "creative_manifest.assets.hero_image", + "recovery": "correctable", + "details": { + "violations": [ + { + "pointer": "/creative_manifest/assets/hero_image", + "criterion": "1.4.3", + "criterion_source": "WCAG21", + "required_level": "AA", + "failure_kind": "contrast_ratio_insufficient", + "remediation": "For normal-sized text, increase contrast to at least 4.5:1; evaluate WCAG exceptions separately." + } + ] + } +} +``` + +`criterion` and `criterion_source` identify the external requirement without +freezing an AdCP-owned WCAG corpus. Portable consumers use that pair after +validating the details schema. `failure_kind` is a bounded diagnostic vocabulary; +vendor extensions use an `x_` prefix. `remediation` remains untrusted advisory +text. This shape does not activate an accessibility policy by itself and is +distinct from `error.issues[]`, which is reserved for JSON Schema validation +output. + +To stay within the transport error safety limit, producers include at most four +complete violations and keep the complete serialized error at or below 4096 +bytes. When additional failures or remediation text are omitted, they set +`details.truncated: true`. Producers omit whole extra violations or remediation +text rather than truncating an RFC 6901 pointer token. + +Pointers use RFC 6901 and are always rooted at the complete task request, like +the protocol's core error pointers. They are not rooted at the nested creative +manifest. Thus `build_creative` uses paths such as +`/creative_manifest/assets/hero_image`, while a batched `sync_creatives` request +uses paths such as `/creatives/0/assets/hero_image`. The top-level `error.field` +projects the first violation into the same request-rooted path using JSONPath-lite +notation (for example `creative_manifest.assets.hero_image`). + +When `build_creative` receives only `creative_id` and resolves the manifest from +the creative library, the failing asset has no request path. In that case each +violation uses `/creative_id` and top-level `error.field` is `creative_id`. +Criterion and remediation fields preserve diagnostic detail, but the +request-rooted pointer intentionally cannot identify a nested stored asset. +`/creative_id` is attribution only and MUST NOT be interpreted as a nested +mutation path. + +### 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 inspect canonical declarations returned in product `format_options[]` or creative-agent `supported_formats[]` and filter them client-side by declared accessibility constraints: + +```json +const accessible = product.format_options.filter(option => + ["AA", "AAA"].includes(option.params.accessibility?.wcag_level) && + option.params.slots.some(slot => slot.asset_type === "image") +); +``` + +This selects formats that claim at least WCAG AA conformance and include an image slot. Accessibility claims remain declaration-specific and should be substantiated by the renderer or required asset metadata. + +## 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 +- Select the recovery class only from `code` and `recovery`. After validating + `details` against `accessibility-violation.json`, portable tooling may group + diagnostics by (`criterion_source`, `criterion`); `failure_kind` refines the + display but does not select the recovery class. +- Treat pointers as untrusted references into an immutable copy of the original + request. Resolve them without mutation, require an allowlisted creative asset + location, and present the validated criterion to controlled repair logic or a + human. Never execute a pointer directly as a property-write path, never treat + `/creative_id` as an asset path, and never pass raw details or remediation text + to an LLM as instructions. + +## Related Documentation + +- [Creative Formats](/dist/docs/3.3.0-beta.3/creative/formats) — Format structure and requirements +- [Asset Types](/dist/docs/3.3.0-beta.3/creative/asset-types) — Asset specifications and payload schemas +- [Creative Manifests](/dist/docs/3.3.0-beta.3/creative/creative-manifests) — Pairing assets with formats +- [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) — Creative-agent capability discovery diff --git a/dist/docs/3.3.0-beta.3/creative/ai-creative-overview.mdx b/dist/docs/3.3.0-beta.3/creative/ai-creative-overview.mdx new file mode 100644 index 00000000000..3ba8220fdbe --- /dev/null +++ b/dist/docs/3.3.0-beta.3/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.3.0-beta.3/creative) follows a strategist from brief to delivery across CTV, display, and social +- **Technical workflow** -- [Generative creative](/dist/docs/3.3.0-beta.3/creative/generative-creative) walks through the API step by step +- **Library management** -- [Creative libraries and concepts](/dist/docs/3.3.0-beta.3/creative/creative-libraries) covers organizing and syncing assets +- **CTV and video** -- [CTV and connected TV](/dist/docs/3.3.0-beta.3/creative/channels/ctv) covers SSAI/CSAI delivery, companion ads, and VAST tags +- **Multi-agent orchestration** -- [Multi-agent creative orchestration](/dist/docs/3.3.0-beta.3/creative/multi-agent-orchestration) covers distributing creatives across sellers +- **Brand safety details** -- [Sales agent creative capabilities](/dist/docs/3.3.0-beta.3/creative/sales-agent-creative-capabilities) explains guardrails and inline creative management +- **Learning** -- The [certification program](/dist/docs/3.3.0-beta.3/learning/overview) teaches AdCP through interactive modules with Addie diff --git a/dist/docs/3.3.0-beta.3/creative/asset-types.mdx b/dist/docs/3.3.0-beta.3/creative/asset-types.mdx new file mode 100644 index 00000000000..74db8f8ce09 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/asset-types.mdx @@ -0,0 +1,729 @@ +--- +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" +--- + +> **Canonical-formats readers**: this page describes asset types and their payload shapes — the same in v1 and the canonical-formats path. For how assets map to canonical-format slots via `asset_group_id`, see [canonical-formats](/dist/docs/3.3.0-beta.3/creative/canonical-formats) and the [migration guide](/dist/docs/3.3.0-beta.3/creative/canonical-formats-migration). v1 uses `asset_id` + `asset_role`; canonical formats use `asset_group_id` referencing the canonical vocabulary registry. Both paths use the same asset payload schemas — only the slot-key vocabulary differs. + +Creative formats in AdCP use standardized asset types with well-defined properties. Assets are the discrete, typed building blocks used by formats to define requirements and by manifests to supply concrete values. + +Standardizing asset types ensures consistency across formats and makes requirements easier for buyers and systems to understand. + +## Asset types in v2 + +The full set of asset types valid in a v2 format `slots` declaration's `asset_type` field: + +| asset_type | What it carries | Where defined | +|---|---|---| +| `image` | Image file (jpg/png/gif/webp/svg) | `/schemas/core/assets/image-asset.json` | +| `video` | Video file (mp4/webm/mov) | `/schemas/core/assets/video-asset.json` | +| `audio` | Audio file (mp3/aac/wav) | `/schemas/core/assets/audio-asset.json` | +| `text` | Plain text (headline, body, script) | `/schemas/core/assets/text-asset.json` | +| `markdown` | Markdown text | `/schemas/core/assets/markdown-asset.json` | +| `url` | URL with `url_type` discriminator (clickthrough, tracker_pixel, third-party tag) | `/schemas/core/assets/url-asset.json` | +| `html` | Inline HTML | `/schemas/core/assets/html-asset.json` | +| `display_tag` | Atomic third-party URL, inline markup, or paired redirect | `/schemas/core/assets/display-tag-asset.json` | +| `css` | CSS rules | `/schemas/core/assets/css-asset.json` | +| `javascript` | JavaScript | `/schemas/core/assets/javascript-asset.json` | +| `vast` | VAST tag (URL or inline XML), VAST 2.x-4.x | `/schemas/core/assets/vast-asset.json` | +| `daast` | DAAST tag (URL or inline XML), 1.0-1.1 | `/schemas/core/assets/daast-asset.json` | +| `webhook` | Webhook URL for async creative production | `/schemas/core/assets/webhook-asset.json` | +| `brief` | Free-text creative brief (input to generative production) | `/schemas/core/assets/brief-asset.json` | +| `catalog` | Reference to a synced catalog (sponsored_placement) | `/schemas/core/assets/catalog-asset.json` | +| `published_post` | Reference to an already-published post that the seller resolves after authorization/review | `/schemas/core/assets/published-post-asset.json` | +| `zip` | Zip archive (HTML5 banner bundle) | `/schemas/core/assets/zip-asset.json` | +| `vast_tracker` | Single VAST `Tracking` event URL (decomposed) | `/schemas/core/assets/vast-tracker-asset.json` | +| `daast_tracker` | Single DAAST `Tracking` event URL (decomposed) | `/schemas/core/assets/daast-tracker-asset.json` | +| `pixel_tracker` | Renderer-fired HTTP tracker (image pixel or JS include) for any web-rendered canonical. `event` (impression / viewable_mrc_* / viewable_video_50 / audible_video_complete / start / first_quartile / midpoint / third_quartile / complete / click / custom) × `method` (img / js). Maps to IAB OpenRTB Native 1.2 `imptrackers[]` / `jstracker` / `eventtrackers[]` / `link.clicktrackers[]`; standardized event types 1-4 are reused, while audible completion and the playback events (`start`, quartiles, `complete`) are AdCP-defined and `custom` can carry exchange/vendor extensions | `/schemas/core/assets/pixel-tracker-asset.json` | +| `card` | Typed carousel card containing image or video media | `/schemas/core/assets/card-asset.json` | +| `video_brief` | Structured timed generation prompts | `/schemas/core/assets/video-brief-asset.json` | + +In a v2 manifest's `assets` map, the **slot key** is the canonical's `asset_group_id` (e.g., `image_main`, `video_main`, `script`, `cards`, `landing_page_url`) and the **value** carries the matching asset payload with its `asset_type` discriminator. The format declaration's `slots[].asset_type` tells the validator which payload schema applies. + +Published-post references use the same rule: a product that accepts existing posts declares a `published_post` slot, and the manifest value carries `asset_type: "published_post"` with a `post_url` or `platform_post_id`. + +### Structured video briefs + +Starting in AdCP 3.3, the `video_brief` asset carries the existing structured VideoBrief inside a manifest. A product declares a slot with `asset_type: "video_brief"`; each supplied brief has ordered `segments` with a duration and generation prompt. The segment durations must satisfy the selected product's duration contract. The generated output is a separate `video` asset. + +{/* Using latest because the typed video-brief asset is not yet released. */} +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/assets/video-brief-asset.json", + "asset_type": "video_brief", + "segments": [ + { "order": 1, "duration_ms": 15000, "prompt": "An Acme hiking boot on a forest trail." } + ] +} +``` + +The legacy declaration placeholder `object` is not a manifest payload type. Use `card` for carousel elements and `video_brief` for timed video-generation briefs; every value in an asset map must validate as a supported typed asset, including when its slot name is platform-specific. + +## 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.3.0-beta.3/creative/asset-types/index.json) - Links to all payload schemas +- Core Asset Schemas at `/schemas/3.3.0-beta.3/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) + +File-size limits use decimal, exact units throughout AdCP: `*_kb` means 1 KB = 1,000 bytes, `*_mb` means 1 MB = 1,000,000 bytes, and `*_bytes` is an exact byte count. Do not interpret KB/MB fields as KiB/MiB. + +## 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 decimal megabytes (1 MB = 1,000,000 bytes) +- `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 +- `pixel_ratio`: Optional intrinsic pixels per logical render pixel (for example, `2` for a 600×500 asset rendered into a logical 300×250 slot). SDKs infer it when the target format's dimensions are sufficient. +- `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 decimal kilobytes (1 KB = 1,000 bytes) +- `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:** +- High-density delivery in any canonical: declare accepted densities with `slots[].pixel_ratios`; use top-level `params.pixel_ratios` for the `image` canonical's `image_main` slot. +- Multi-rendition delivery: add `slots[].required_pixel_ratios` and provide that image slot as an array with one rendition per required density. Accepted densities outside the required subset remain optional. +- 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) | +| `ad_request` | Invokes a third-party display creative endpoint at impression time | +| `tracker_pixel` | Fires HTTP GET, expects 1×1 pixel or 204 response (impression / event / 3P trackers) | +| `tracker_script` | Loads as a `" + }, + "clickthrough_url": { + "asset_type": "url", + "url": "https://acmecorp.example.com/holiday-sale?campaign={MEDIA_BUY_ID}" + } + } + } +} +``` + +The `CLICK_URL` macro was substituted with the provided value; `CACHEBUSTER` remains as a placeholder for the sales agent to resolve at serve time. + +Do not use `recipe_hash` to deduplicate ad tags, prove legal/disclosure equivalence, or join build results to served delivery data. Use `build_variant_id` and the promoted `creative_id` for lineage and reporting. + + +**Cross-agent workflow**: When creative generation and media buying are handled by different agents, use `build_creative` on the creative agent to produce a manifest with tags, then [`sync_creatives`](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/creative/sales-agent-creative-capabilities). + + +### Multi-format generation + +Generate creatives for multiple formats in a single call using `target_capability_ids`. The agent produces one manifest per format from the same source assets and brief: + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-request.json", + "idempotency_key": "d4e5f6a7-b8c9-4012-d345-6789abcdef01", + "message": "Create display banners for our spring campaign", + "target_capability_ids": [ + "display_image_300x250", + "display_image_728x90", + "display_image_320x50" + ], + "brand": { + "domain": "acmecorp.com" + }, + "creative_manifest": { + "format_kind": "image", + "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` instead of singular `creative_manifest`. Each result is a complete canonical manifest, ready for `sync_creatives` or [`preview_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative): + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "creative_manifests": [ + { + "format_kind": "image", + "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_kind": "image", + "assets": { /* same structure, adapted for 728x90 */ } + }, + { + "format_kind": "image", + "assets": { /* same structure, adapted for 320x50 */ } + } + ] +} +``` + +Multi-capability requests are atomic — if any capability fails, the entire request fails. Response order matches `target_capability_ids`; each manifest's `format_kind` describes the resulting canonical contract. + +### 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": [ + { "target_capability_id": "preview_image_300x250", "creative_manifest": { /* 300x250 manifest from build response */ } }, + { "target_capability_id": "preview_image_728x90", "creative_manifest": { /* 728x90 manifest from build response */ } }, + { "target_capability_id": "preview_image_320x50", "creative_manifest": { /* 320x50 manifest from build response */ } } + ] +} +``` + +Resolve each preview route from a `creative.supported_formats[]` entry whose `operations` contains `preview` and whose canonical declaration satisfies that manifest. Build and preview capability IDs may differ, even on the same agent. + +To refine a single format from a multi-format build, call `build_creative` again with `target_capability_id` (singular) and pass back that format's manifest. You don't need to rebuild all formats — just iterate on the one that needs work. + + +Multi-format requests with `include_preview: true` generate one default preview per format. Custom `preview_inputs` are only supported with single-format requests. For multi-format builds where you need context-specific previews (device variants, different contexts), use a separate `preview_creative` batch call after building. + + +### Transformers & variants + +Select a transformer (discovered via [`list_transformers`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_transformers)) with `transformer_id`, supply a typed `config` keyed to its params, and ask for alternatives with `max_variants`. The agent returns the [variant response](#variant-response): `creatives[]`, each holding a `variants[]` array. Read `recommended` / `rank` to surface the agent's best-of-N pick; traffic the variant(s) you want by their `build_variant_id`. + +Resolutions and quality tiers go on `target_capability_ids` (or `quality`), not on `variant_axis` — variants are alternatives for the *same* format. + + + +```javascript test=false +import { testAgent } from '@adcp/sdk/testing'; +import { BuildCreativeResponseSchema } from '@adcp/sdk'; + +const result = await testAgent.buildCreative({ + account: { account_id: 'acct_acme' }, + transformer_id: 'audiostack_voiceover', + config: { voice: 'isaac', speaking_rate: 1.1, mastering_preset: 'podcast' }, + target_capability_id: 'audio_vo', + creative_manifest: { + format_kind: 'audio_hosted', + assets: { script: { asset_type: 'text', content: 'Discover the new winter collection.' } }, + }, + max_variants: 3, + variant_axis: { dimension: 'best_of_n', label: 'Read takes' }, + keep_mode: 'keep_one', + idempotency_key: '0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5', +}); + +const parsed = BuildCreativeResponseSchema.parse(result); +for (const creative of parsed.creatives ?? []) { + for (const variant of creative.variants) { + console.log(variant.build_variant_id, variant.rank, variant.recommended, variant.vendor_cost); + } +} +``` + +```python test=false +import asyncio +from adcp.testing import test_agent +from adcp import BuildCreativeResponse + +async def main(): + result = await test_agent.build_creative( + account={"account_id": "acct_acme"}, + transformer_id="audiostack_voiceover", + config={"voice": "isaac", "speaking_rate": 1.1, "mastering_preset": "podcast"}, + target_capability_id="audio_vo", + creative_manifest={ + "format_kind": "audio_hosted", + "assets": {"script": {"asset_type": "text", "content": "Discover the new winter collection."}}, + }, + max_variants=3, + variant_axis={"dimension": "best_of_n", "label": "Read takes"}, + keep_mode="keep_one", + idempotency_key="0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", + ) + parsed = BuildCreativeResponse.model_validate(result) + for creative in parsed.creatives or []: + for variant in creative.variants: + print(variant.build_variant_id, variant.rank, variant.recommended, variant.vendor_cost) + +asyncio.run(main()) +``` + + + +You pay for all three takes (`per_unit` × 3); `keep_mode` is advisory. Keeping is a separate trafficking step on the chosen `build_variant_id` — use that id as the `creative_id` when promoting the produced manifest through [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives). The kept variant then lazily earns a creative record whose `creative_id` is the build leaf id and flows to [`report_usage`](/dist/docs/3.3.0-beta.3/accounts/tasks/report_usage) and delivery reporting. + +### Paid build with pricing + +When the creative agent charges and `account` is provided, the response includes pricing fields. The agent selects the applicable pricing option server-side based on the account's rate card and the work performed — the buyer does not pass `pricing_option_id` in the request. + +**Per-unit pricing (transformation agent)**: + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/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_capability_id": "display_728x90" +} +``` + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/creative/task-reference/list_creatives#pricing). The buyer passes it in [`report_usage`](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { "..." : "..." }, + "pricing_option_id": "po_video_cpm", + "vendor_cost": 0, + "currency": "USD" +} +``` + +## Response format + +### Single-format response + +When the request uses `target_capability_id`, the response contains a single creative manifest: + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "recipe_hash": "rh_winter_300x250_4c2a", + "creative_manifest": { + "format_kind": "image", + "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_capability_ids`, the response contains an array of creative manifests: + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "creative_manifests": [ + { + "format_kind": "image", + "assets": { /* ... */ } + }, + { + "format_kind": "image", + "assets": { /* ... */ } + } + ] +} +``` + +### Variant response + +When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `refine_from_build_variant_id`, the agent returns the `BuildCreativeVariantSuccess` shape — the third success shape (`oneOf` member 3 of 6) alongside the single- and multi-format responses, which are unchanged and still used when you build one creative with one variant. + + +**No fallback.** If you send any of `max_creatives`, `max_variants > 1`, `variant_axis`, or `refine_from_build_variant_id`, you **must** handle `creatives[]` — you will not get `creative_manifest`/`creative_manifests` back. There is no automatic downgrade to the single/multi shapes. + + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creatives": [ + { + "build_creative_id": "bc_card_01", + "catalog_item_ref": { "catalog_type": "product", "item_id": "sku_winter_parka" }, + "variants": [ + { + "build_variant_id": "bv_card01_a", + "recipe_hash": "rh_card01_bestofn_7b91", + "creative_manifest": { "format_kind": "image", "assets": { "...": "..." } }, + "variant_axis_value": "take_1", + "recommended": true, + "rank": 1, + "pricing_option_id": "po_per_image", + "vendor_cost": 0.40, + "currency": "USD", + "consumption": { "images_generated": 1 } + }, + { + "build_variant_id": "bv_card01_b", + "recipe_hash": "rh_card01_bestofn_7b91", + "creative_manifest": { "format_kind": "image", "assets": { "...": "..." } }, + "variant_axis_value": "take_2", + "recommended": false, + "rank": 2, + "pricing_option_id": "po_per_image", + "vendor_cost": 0.40, + "currency": "USD", + "consumption": { "images_generated": 1 } + } + ] + } + ], + "items_total": 150, + "items_returned": 5, + "vendor_cost": 4.00, + "currency": "USD" +} +``` + +_The `creatives[]` array above is abbreviated to one of the 5 returned groups; the aggregate `vendor_cost` of $4.00 covers all 5 groups × 2 variants × $0.40 — it equals the sum of every produced leaf's `vendor_cost`, not just the leaves shown._ + +- **creatives[]**: One entry per built creative group. With `max_creatives`, there is one entry per sampled catalog item (`catalog_item_ref` identifies which item); without catalog fan-out there is a single entry. Treat this as the produced group set; choose among alternatives inside each group's `variants[]`. +- **creatives[].build_creative_id**: Identifies a built creative within this response. +- **creatives[].catalog_item_ref**: Present on catalog fan-out — an object identifying the source catalog item via `item_id` (plus optional `catalog_type`). +- **creatives[].signal_condition**: Present on signal fan-out — the SignalTargeting condition this creative group is FOR (e.g. weather=rain). Shares `signal_ref` identity with sales-side package targeting so the sales agent can match and reject incompatible assignments. +- **creatives[].errors[]**: Present only on a *failed* catalog item — catalog fan-out is non-atomic, so a failed item is returned as a `creatives[]` entry carrying `errors[]` and no `variants[]`, without failing the batch. +- **creatives[].variants[]**: The choose-among alternatives produced for this creative group. Length is at most `max_variants`. Each variant carries its own complete `creative_manifest`. +- **variants[].build_variant_id**: Identifies a single variant — the leaf-level **lineage anchor**. This is its **own namespace** — never reuse a `preview_id` (a [`preview_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative) render) or a served `variant_id` (a delivery-time identifier) here. You traffic a chosen build by passing its `build_variant_id`. +- **variants[].recipe_hash**: Optional ETag-style identity for the build-determining inputs that produced the leaf. It is agent-computed, opaque, and comparable only within the same agent. Best-of-N leaves from the same source recipe SHOULD share a value. It is useful for cache transparency, cost-avoidance hints, and recipe-level grouping, but it is not output equality, not legal/disclosure equivalence, and not the build-to-delivery join. +- **variants[].parent_build_variant_id**: Present only on a refined variant ([Refinement](#refinement)) — the `build_variant_id` of the source it was refined from. Absent for first-generation builds. +- **variants[].variant_axis_value**: The value of the `variant_axis` dimension this variant represents (e.g. a voice, a theme). +- **variants[].recommended** / **variants[].rank**: Best-of-N signals — `recommended` flags the agent's top pick; `rank` orders the variants. These plus `variant_axis` and `keep_mode` are how best-of-N is expressed. +- **variants[].eval**: Present only when the request supplied an advisory [`evaluator`](#request-parameters) and the agent advertises `creative.supports_evaluator`. The rank-side of the [`get_creative_features`](/dist/docs/3.3.0-beta.3/governance/creative/get_creative_features) feature oracle. `eval.features[]` is **`creative-feature-result[]`** — the same shape `get_creative_features.results[]` returns (e.g. a `creative_quality_score` number, an `on_brief` categorical, a calibrated `predicted_performance`); the wrapper is open, each feature item is closed. The agent runs a **gate-then-rank** pipeline over its best-of-N: it evaluates each leaf, drops leaves failing the hard `evaluator.feature_requirement[]` gate from its recommended survivors, then orders survivors by `evaluator.rank_by` (`[{feature_id, direction}]`) — which is what `recommended`/`rank` reflect. The gate is **internal pruning** of which leaves the agent recommends from its own exploration, not an AdCP-layer block: what is produced and billed is governed by `max_variants`/`max_creatives`/`max_spend`, so this block appears only on returned (billed) leaves. Feature discovery uses `get_adcp_capabilities.governance.creative_features`; the evaluator source emits values over that feature vocabulary, and there is no separate evaluator feature catalog. The exemplars form calibrates one `predicted_performance` feature in [0,1]; a pass/warn/fail verdict is a categorical string feature value gated via `feature_requirement.allowed_values` — `eval.features[]` is a feature measurement, never a stored verdict. When the evaluator names an external agent, it must be on the seller's `creative_policy.accepted_verifiers[]` (off-list → [`EVALUATOR_AGENT_NOT_ACCEPTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-evaluator-agent-not-accepted)); the producing agent authenticates its outbound call on the transport, not inside the evaluator payload. Sellers SHOULD populate `eval.calls_used` / `eval.seconds_used` when `agent_url` was used and an `eval_budget` was supplied. +- **variants[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. Whenever the build reports cost (the aggregate `vendor_cost` is present), every produced leaf MUST carry its own `vendor_cost` + `currency` (schema-enforced) — leaves are the billing source of truth, and an untrafficked leaf is reconciled from this receipt alone. A CPM-deferred leaf reports `vendor_cost: 0`. +- **items_total** / **items_returned**: For catalog fan-out, the total eligible catalog items and how many creatives were returned (e.g. "5 of 150"). +- **leaves_total** / **leaves_returned**: Leaf-granular counts — total leaves the request would produce vs. how many were actually produced/billed. When `budget_status` is `capped`, `leaves_returned` < `leaves_total` is the shortfall (works even for variant-only fan-out with no catalog). +- **vendor_cost** / **currency**: Aggregate cost across all produced variants in the response. +- **keep_mode_applied**: Echoes the `keep_mode` the agent applied (present when the request set it). `keep_mode` is advisory and doesn't change what's produced or billed, so this is your confirmation the hint was received — the audit trail for a "I asked for keep_one but was billed for N" dispute. +- **selection_strategy_applied**: Echoes the `selection_strategy` the agent applied (when `max_creatives` sampling occurred). The ranking itself shows in `rank`/`recommended` on the leaves. +- **budget_status**: `complete` (default; absent == complete) or `capped` — a `max_spend` ceiling stopped production early (a successful partial build, not a failure). See [Spend controls](#spend-controls). +- **errors[]**: Advisory (non-terminal) entries on an otherwise-successful build — e.g. a [`BUDGET_CAP_REACHED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-budget-cap-reached) notice when `budget_status` is `capped`. + + +You pay for **every produced variant** (`per_unit` × N), not just the ones you keep. Keeping is a separate client act of trafficking the chosen `build_variant_id`(s), typically by using each kept leaf as `creative_id` on [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives). A kept variant lazily earns a creative record when it is added to the library or first trafficked; that `creative_id` is what flows to [`report_usage`](/dist/docs/3.3.0-beta.3/accounts/tasks/report_usage) and delivery reporting. An unkept variant is still billed but never earns a `creative_id`. + + + +**Atomicity differs by axis.** Variant production *per format* is atomic — if one variant for a format fails, that format's build fails. Catalog fan-out (`max_creatives`, per item) is **non-atomic**: individual `creatives[]` entries may succeed while others fail. A failed item is returned as a `creatives[]` entry carrying `errors[]` (and no `variants[]`); the batch still succeeds. + +**Build variants, representations, revisions, and served variants are separate.** `build_variant_id` identifies a candidate produced by this task. `representation_id` identifies one equivalent trafficking form inside a complete `revision_id`. The served `variant_id` in [`get_creative_delivery`](/dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery) identifies an immutable execution that actually ran. Keeping a build variant may promote it to a durable creative/revision; representation selection and later serving do not reuse its build ID as either of the other identities. + +**Resolutions and quality tiers are not variants.** Multiple sizes or quality levels belong on the format axis — pass them as `target_capability_ids` (or `quality`), not as `variant_axis` values. Variants are alternatives *for the same format* (different voice, theme, or best-of-N take). + + +### Refinement + +Conversational refinement re-builds a prior variant with free-form direction — "make it warmer", "tighten the CTA". The typed `config` surface is closed by design, so free-form intent rides the **open** surface that already exists: the `message` field (whose description is already *"For refinement, this describes the desired changes"*). Refinement is therefore not a new task — it's `build_creative` with one extra input: + +- Pass `refine_from_build_variant_id` (a prior leaf's `build_variant_id`) plus your instruction in `message` and any `config` delta. +- The agent re-builds from that leaf and returns **new** variants, each with `parent_build_variant_id` set to the source. A refinement is **never a mutation** — the parent leaf is unchanged, and the new leaf earns its own `build_variant_id` (and, on trafficking, its own `creative_id`). +- `transformer_id` and the target format(s) are **inherited** from the parent; you don't repeat them. Passing a `transformer_id` or target format that differs from the parent's is an [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request). `config` is applied as a **delta** over the parent's config. +- Composes with `max_variants` / `variant_axis` (e.g. "three warmer takes" → three refined leaves), but **not** with `max_creatives` / catalog fan-out — you refine one produced creative, not a catalog. +- Requires the agent to advertise `creative.supports_refinement: true` (it retains produced leaves for an agent-defined window). Agents that retain nothing return [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-unsupported-feature); refine a buyer-held manifest instead via the transform path (`creative_manifest` + `message`). An unknown or no-longer-retained ref returns [`REFERENCE_NOT_FOUND`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-reference-not-found) with `error.field` set to `refine_from_build_variant_id`. + +Any build can be refined, not just variant builds: a single-format `BuildCreativeSuccess` carries an optional `build_variant_id` (present when the agent supports refinement) that you pass as `refine_from_build_variant_id`. Multi-format builds that need per-output refinable leaves should request the variant shape (`max_variants`), since the bare `creative_manifests[]` array carries no leaf ids. + +AI-derivative attribution rides the manifest's existing `provenance`; `parent_build_variant_id` carries only the lineage edge (refinements chain into a tree). + +#### Compliance and approval across refinement + +- **Compliance carries forward.** The effective compliance set of a refinement is the `compliance` set (`required_disclosures`, `prohibited_claims`) of the **parent leaf's retained build inputs**. A refinement request carries no independent compliance authority: if it also supplies a brief, that can only **add** to the inherited set (union), never remove or narrow it. `message` and `config` cannot reduce the set. A parent built without a brief has nothing to enforce. Agents that advertise `creative.supports_refinement` therefore retain each leaf's compliance inputs for the same window they retain the leaf. +- **Required disclosures MUST be present on every refinement leaf.** The agent MUST verify before returning a leaf that every applicable `required_disclosures` entry is still satisfied in the output (text-asset disclosures are checkable by the agent; for disclosures rendered into raster or video output the agent attests by returning the leaf). A refinement `message` that instructs removal or contradiction of a required disclosure (for example *"remove the disclaimer"*) MUST fail the whole call with a call-level `BuildCreativeError` carrying [`COMPLIANCE_UNSATISFIED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-compliance-unsatisfied) (recovery: `correctable`) — not a partial success. Refinement forbids fan-out, so this is a call-level error, not a `creatives[].errors[]` entry. +- **Prohibited claims are a generation constraint, not a validation guarantee.** The agent MUST NOT knowingly generate a claim listed in `prohibited_claims` and MUST fail with `COMPLIANCE_UNSATISFIED` when `message` explicitly instructs one. Free-form generated output cannot be exhaustively screened; buyers in regulated verticals keep their own review before trafficking. +- **Approval is never inherited.** A refined leaf is a new creative on promotion (it earns its own `creative_id`). It carries no approval state from its parent, and an approval granted to the parent by a seller or by a buyer-side governance agent does not extend to it. This does not mandate a particular review process — sellers may auto-approve under their own policy — it only forbids treating lineage as approval. + + +None of this is a spend or approval control. Every refinement leaf is billed at build time, before any trafficking-time review, and `build_creative` is often served by a third-party creative agent that does not own approval. Bound a refinement loop with [`max_spend` and `mode: "estimate"`](#spend-controls) and review copy before trafficking; the compliance carry-forward above only guarantees that refinement cannot silently drop what the original brief required. + + +```json test=false +{ + "refine_from_build_variant_id": "bv_card01_a", + "message": "Warmer lighting, and move the logo to the lower-right.", + "max_variants": 3, + "account": { "account_id": "acct_acme" }, + "idempotency_key": "7f2a1b3c-4d5e-6f70-8192-a3b4c5d6e7f8" +} +``` + +### Spend controls + +Fan-out and refinement can produce many independently-billed leaves (`max_creatives` × `max_variants`), and `per_unit` pricing gives a *rate* but not the *unit count* in advance (a 6-second voiceover and a 60-second one cost 10× at the same rate). Two opt-in controls, both gated by `creative.supports_spend_controls`: + +- **Estimate first (`mode: "estimate"`).** A dry run: the agent produces and bills nothing and returns a `BuildCreativeEstimate` with a `cost_low`/`cost_high` band (and `basis`: `fixed` = exact, `estimated_units` = generative projection, `cpm_deferred` = cost accrues at serve). The band is the load-bearing piece — the seller derives it from your actual inputs, so you don't have to guess unit counts. +- **Cap the call (`max_spend: { amount, currency }`).** A hard stop: the agent produces leaves until the next would push aggregate `vendor_cost` over `amount`, then returns the partial `BuildCreativeVariantSuccess` with `budget_status: "capped"` and an advisory `BUDGET_CAP_REACHED` in `errors[]` — every returned leaf is real and billed, nothing produced is discarded. The leaf-granular shortfall is `leaves_returned` < `leaves_total` (not `items_returned`/`items_total`, which count catalog items and don't capture a variant-only or mid-item cap); the `BUDGET_CAP_REACHED` advisory is the authoritative cap signal. If even the first leaf would exceed the cap, the call fails with a terminal `BUDGET_CAP_REACHED`. `currency` must match the rate card (no FX) or the request is rejected `INVALID_REQUEST` (`error.field: max_spend.currency`). `max_spend` bounds only **build-time** `vendor_cost` — CPM-priced builds (`basis: cpm_deferred`) are 0 at build time and accrue at serve, so the cap never engages; bound a CPM fan-out with `max_creatives` instead. + +They compose: estimate to get `cost_high`, then execute with `max_spend` = `cost_high` × a safety margin (CPM builds excepted — their `cost_high` is 0 at build time). `max_spend` caps a **single call**; to bound an autonomous **refinement loop**, track aggregate `vendor_cost` across calls and stop issuing them (a buyer responsibility in this revision — a protocol-level session budget is deferred to the working group). + + +`max_spend` and `mode: "estimate"` require the agent to advertise `creative.supports_spend_controls`; otherwise they're rejected with `UNSUPPORTED_FEATURE`. They're only meaningful with `bills_through_adcp: true` (an out-of-band biller has no AdCP cost to cap against). + +**Governance applicability.** A creative service advertising `adcp.governance_enforcement` for `build_creative` requires an approved `governance_context` only when the execution incurs positive vendor cost. Estimate-only calls and zero-cost retrieval are exempt. A creative ad server that never commits or meters spend through AdCP does not acquire a [`sync_governance`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_governance) obligation merely by trafficking creative; if it later exposes an AdCP-billed mutation, that commitment-bearing task must be declared and governed explicitly. + + +### Estimate response + +A `mode: "estimate"` request returns the `BuildCreativeEstimate` shape (`oneOf` member 4 of 6) — produced and billed nothing, just a projected cost band: + +```json test=false +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "mode": "estimate", + "estimate": { + "items_total": 150, + "items_to_produce": 5, + "variants_per_item": 3, + "leaves_total": 15, + "currency": "USD", + "cost_low": 6.00, + "cost_high": 9.00, + "cost_expected": 7.50, + "basis": "estimated_units" + }, + "expires_at": "2026-06-01T00:00:00Z" +} +``` + +- **estimate.leaves_total** = `items_to_produce` × `variants_per_item` (× `conditions_total` when `signal_conditions` was sent) — the number of billable leaves `mode: "execute"` would produce. +- **estimate.cost_low / cost_high / cost_expected**: the projected aggregate cost band. The seller derives it from your actual inputs. +- **estimate.basis**: `fixed` (per-format flat — `cost_low == cost_high`, exact), `estimated_units` (generative `per_unit`; the band reflects unit-count uncertainty), or `cpm_deferred` (CPM — build-time cost 0, accrues at serve, so the band is 0). +- **estimate.per_leaf** (optional): per-leaf breakdown. +- Estimates are **advisory / non-binding** in this revision (binding quotes are deferred to the working group). + +### Field descriptions + +- **creative_manifest**: (single-format) The complete creative manifest ready for use with `sync_creatives` or `preview_creative` +- **errors**: Optional. Non-terminal advisory entries on an otherwise-successful single- or multi-capability build (for example [`RIGHTS_CHANNEL_VIOLATION`](#rights-channel-restrictions)). When `creative_manifest` or `creative_manifests` is present, `errors[]` is advisory and the build did not fail; only a response with `errors[]` and no manifest is a failure. +- **creative_manifests**: (multi-capability) Array of portable canonical creative manifests, one per requested capability and in request order. Each contains its own `format_kind` and, when needed, `format_option_ref`. +- **format_kind**: The canonical format produced by the selected capability +- **assets**: Map of asset keys to asset content — includes creative content (images, text, URLs), catalogs, briefs, and anything else the format requires +- **expires_at**: Optional ISO 8601 timestamp when generated asset URLs in the manifest expire. Set to the earliest expiration across all generated assets. Re-build the creative after this time to get fresh URLs. Not present when the manifest contains no expiring URLs (e.g., pure text generation or assembly-only transforms). +- **preview**: Optional. Present when `include_preview` was true in the request and the agent supports inline preview. Contains the same content fields as a `preview_creative` single response (`previews`, `interactive_url`, `expires_at`) minus the `response_type` discriminator, so clients can reuse the same preview rendering logic. Preview URLs follow the same durability contract as `preview_creative`: they remain dereferenceable until `expires_at`, or until explicit out-of-band revocation when no expiration is present. For single-capability responses, each entry in `previews[]` corresponds to an input set from `preview_inputs`. For multi-capability responses, each entry includes a `capability_id` and corresponds to one requested capability (one default preview per capability; `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.3.0-beta.3/media-buy/build-creative-response.json", + "status": "failed", + "errors": [ + { + "code": "COMPLIANCE_UNSATISFIED", + "message": "Required disclosure cannot be rendered in this format", + "field": "creative_manifest.assets.brief.compliance.required_disclosures[0]", + "details": { + "disclosure_text": "Past performance is not indicative of future results.", + "position": "footer", + "reason": "Format display_mobile_320x50 does not support footer position" + }, + "suggestion": "Use a format that supports footer disclosures, or change position to 'overlay'" + } + ] +} +``` + +Creative agents MUST validate that all `required_disclosures` can be satisfied in the target format before generating the creative. If any disclosure cannot be placed as specified, the entire request fails. This ensures regulated creatives are never served without required legal text. + +The same obligations apply to [refinement](#compliance-and-approval-across-refinement): the compliance set is inherited from the parent leaf's retained inputs, and a refinement that would drop or contradict a required disclosure fails with the same error. + +### Rights channel restrictions + +A `creative_manifest.rights[]` entry may carry an advisory `channels[]` and/or `excluded_channels[]` restriction (values from the [media channel taxonomy](/dist/docs/3.3.0-beta.3/reference/media-channel-taxonomy)). **Experimental** ([status](/dist/docs/3.3.0-beta.3/reference/experimental-status); feature id `brand.rights_lifecycle`). The restriction is wire metadata, not enforcement: the buyer's orchestrator and the serving party remain the enforcement points, and a creative agent is not an enforcement principal for the rights holder. + +The effective set of permitted channels for one rights entry: + +| `channels` / `excluded_channels` | Permitted channels | +|---|---| +| both absent | every channel (no restriction) | +| `excluded_channels` only | every channel except those in `excluded_channels` | +| `channels` present | only those in `channels`, less any also in `excluded_channels` (deny wins) | +| `channels: []` | none | + +Creative agents SHOULD, before generating, compare each `rights[].channels` / `excluded_channels` restriction against the channel each requested output serves (for example the `applies_to_channels` of the target capability, where declared), and SHOULD NOT build for a channel outside the effective set. When a request would do so, the agent SHOULD surface a structured [`RIGHTS_CHANNEL_VIOLATION`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-rights-channel-violation) rather than silently building. Because the restriction is advisory, the agent either proceeds and returns the code as a non-terminal entry in the success response's `errors[]`, or declines the affected output with a terminal `BuildCreativeError`. When `approval_status` is `pending`, the entry grants no placement on any channel until the holder approves — the existing rule that a pending grant never authorizes serving — but that is a placement and serving rule: building creative while a grant is pending is the normal path, so a pending status alone is not a reason to raise `RIGHTS_CHANNEL_VIOLATION`. When a manifest carries several `rights[]` entries in force (not expired, `grant_status` not `paused` or `revoked`) and relevant to the output's `uses`, a channel must be permitted by every such entry; `channels` applies to the grant as a whole, so "likeness on social only, voice anywhere" needs separate entries per use. + +The check needs the output's channel. If the agent cannot determine which channels a requested output serves (`applies_to_channels` is optional on a capability), it skips the check and does not emit the code. If the output serves several channels and any one falls outside the effective set, that is a violation. Channels are planning abstractions: denying `ctv` does not by itself restrict the same video running as `olv` inside a CTV app, so a rights holder who cares lists both. A multi-capability request is atomic, so declining one output with a terminal error fails the whole request; prefer the advisory there. In a multi-capability success, point `error.field` at `creative_manifests[N].rights[i].excluded_channels` (or `.channels`) and set `error.details.capability_id`. + +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "image", + "assets": { + "banner_image": { + "asset_type": "image", + "url": "https://cdn.novabrands.example/renders/talent-banner.png", + "width": 300, + "height": 250 + } + }, + "rights": [ + { + "rights_id": "rg_nova_talent_001", + "rights_agent": { "url": "https://rights.novabrands.example/mcp", "id": "nova_rights" }, + "uses": ["likeness"], + "excluded_channels": ["ctv", "linear_tv"] + } + ] + }, + "errors": [ + { + "code": "RIGHTS_CHANNEL_VIOLATION", + "message": "Requested output targets ctv, which these rights exclude", + "field": "creative_manifest.rights[0].excluded_channels", + "details": { "rights_id": "rg_nova_talent_001", "channel": "ctv" }, + "suggestion": "Target a capability on a permitted channel, or obtain a grant that covers ctv" + } + ] +} +``` + +A client reading `errors[]` MUST NOT treat it as failure when `creative_manifest` or `creative_manifests` is present. Platform-level scope inside a channel (for example one social network) is not expressed here; carry it in `ext` until a platform axis is specified. + +## 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.3.0-beta.3/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative) to see how it renders (see [preview_creative](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative)) +3. **Sync**: Use [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/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_capability_id": "display_300x250_generative", + "brand": { "domain": "mybrand.com" }, + "quality": "draft", + "include_preview": true, + "preview_quality": "production", + "creative_manifest": { + "format_kind": "image", + "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_capability_id": "display_300x250_generative", + "brand": { "domain": "mybrand.com" }, + "creative_manifest": { + "format_kind": "image", + "assets": { + "product_catalog": { + "type": "product", + "catalog_id": "winter-products" + } + } + } +} + +// Step 2: Preview (using the output manifest from step 1) +{ + "request_type": "single", + "format_kind": "image", + "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.3.0-beta.3/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_capability_id": "display_300x250_generative", + "brand": { + "domain": "mybrand.com" + }, + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/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_kind": "image", + "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_capability_id": "display_300x250" +} +``` + +**Response**: +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/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_kind": "image", + "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_capability_id": "display_mobile_320x50" +} +``` + +**Response**: +```json +{ + "$schema": "/schemas/3.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/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_capability_id": "display_300x250_generative", + "brand": { + "domain": "acmecorp.com" + }, + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/media-buy/build-creative-request.json", + "idempotency_key": "d0e1f2a3-b4c5-4678-d901-cdef01234567", + "message": "Create a display banner promoting retirement planning advisory services", + "target_capability_id": "display_300x250_generative", + "brand": { + "domain": "pinnaclewealth.com" + }, + "creative_manifest": { + "format_kind": "image", + "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.3.0-beta.3/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_capability_id": "sponsored_product_carousel", + "brand": { + "domain": "novabrands.com" + }, + "creative_manifest": { + "format_kind": "sponsored_placement", + "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 selected `creative.supported_formats[]` declaration exposes both `brief` and `catalog` slots, so the buying agent can sync 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.3.0-beta.3/media-buy/build-creative-request.json", + "idempotency_key": "f2a3b4c5-d6e7-4890-f123-ef0123456789", + "message": "Create a banner for our spring campaign", + "target_capability_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_kind": "image", + "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.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "image", + "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`](/dist/docs/3.3.0-beta.3/creative/task-reference/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.3.0-beta.3/media-buy/build-creative-request.json", + "idempotency_key": "a3b4c5d6-e7f8-4901-a234-f01234567890", + "message": "Create hero images for our top sale items", + "target_capability_id": "sponsored_product_carousel", + "brand": { + "domain": "novabrands.com" + }, + "quality": "draft", + "item_limit": 4, + "creative_manifest": { + "format_kind": "sponsored_placement", + "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.3.0-beta.3/media-buy/build-creative-response.json", + "status": "completed", + "creative_manifest": { + "format_kind": "sponsored_placement", + "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 `target_capability_id`, any catalog or seed assets required by that capability, and `message`. The creative agent generates a canonical output manifest from scratch. +- **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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/creative/task-reference/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_capability_id` +- **Transformation**: full `creative_manifest` + `message` + `target_capability_id` +- **Library retrieval**: `creative_id` + `target_capability_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.3.0-beta.3/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_capability_id": "display_300x250_generative", + "creative_manifest": { + "format_kind": "image", + "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`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-format-not-supported) | The `target_capability_id` (or `target_capability_ids[N]`) is not advertised by this creative agent. Refresh `get_adcp_capabilities.creative.supported_formats[]` and retry with a supported capability. | +| [`VALIDATION_ERROR`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-validation-error) | The `creative_manifest` is malformed or missing required assets for the target format | +| [`CREATIVE_NOT_FOUND`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-creative-not-found) | The `creative_id` does not exist in the agent's library (or in the specified `concept_id`) | +| [`CREATIVE_REJECTED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-creative-rejected) | The creative failed content-policy, brand-safety, or accessibility review. Accessibility failures SHOULD use `error-details/accessibility-violation.json`. | +| [`COMPLIANCE_UNSATISFIED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-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), or a refinement instructs removal or contradiction of a required disclosure or use of a prohibited claim | +| [`RIGHTS_CHANNEL_VIOLATION`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-rights-channel-violation) | Advisory. A requested output targets a channel outside a `rights[].channels` / `excluded_channels` restriction. See [Rights channel restrictions](#rights-channel-restrictions). | diff --git a/dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery.mdx b/dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery.mdx new file mode 100644 index 00000000000..f55fbf4c931 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery.mdx @@ -0,0 +1,495 @@ +--- +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 exposes the `get_creative_delivery` tool — whether that's a dedicated creative service or a [sales agent implementing the Creative Protocol](/dist/docs/3.3.0-beta.3/creative/sales-agent-creative-capabilities). + +**Request Schema**: [`/schemas/3.3.0-beta.3/creative/get-creative-delivery-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/creative/get-creative-delivery-request.json) +**Response Schema**: [`/schemas/3.3.0-beta.3/creative/get-creative-delivery-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/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.3.0-beta.3/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_kind` | Canonical format of this creative | +| `totals` | Aggregate delivery metrics across all variants — see [Delivery metrics fields](#delivery-metrics-fields) below | +| `variant_count` | Total number of distinct `variant_id` rows within this creative (may exceed `variants` array length when `max_variants` is used). | +| `variants` | Array of variant-level delivery data (empty if creative has no variants yet) | + +### Delivery metrics fields + +Fields available on both `creative.totals` and each `variant` entry. Commonly-used subset — see the [delivery-metrics schema](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/delivery-metrics.json) for the full list including incrementality, brand lift, and broadcast metrics. + +| Field | Description | +|-------|-------------| +| `impressions` | Impressions delivered | +| `spend` | Amount spent (in `currency`) | +| `clicks` | Total clicks | +| `ctr` | Click-through rate (clicks/impressions) | +| `cpm` | Cost per thousand impressions ((spend/impressions) × 1000) | +| `cost_per_click` | Cost per click (spend/clicks) | +| `views` | Billable views — threshold varies by format and pricing model | +| `completed_views` | Video/audio completions | +| `completion_rate` | Completion rate (completed_views/impressions); `null` when not applicable (e.g. a non-video buy) | +| `quartile_data` | Audio/video quartile completion data — object with `q1_views`, `q2_views`, `q3_views`, `q4_views`; `null` when not applicable (e.g. a non-video buy) | +| `reach` | Unique reach in units specified by `reach_unit`. Measurement window declared via `reach_window`; without it, do not sum across rows | +| `reach_unit` | Unit of measurement for `reach`; required when `reach` is present | +| `reach_window` | Window semantics for reach/frequency — object with `kind` (`cumulative` for uniques since campaign start, `period` for non-overlapping snapshot, `rolling` for trailing window) and `period` (Duration, required for `period` and `rolling`). Optional but strongly recommended whenever `reach` is present | +| `frequency` | Average frequency per `reach_unit`, measured over `reach_window` | +| `grps` | Gross Rating Points delivered (for CPP) | +| `conversions` | Total attributed conversions; equals the sum of `by_event_type[].count` when present | +| `conversion_value` | Total monetary value of attributed conversions | +| `roas` | Return on ad spend (conversion_value/spend) — see note below | +| `cost_per_acquisition` | Cost per conversion (spend/conversions) — see note below | +| `new_to_brand_rate` | Fraction of conversions from first-time brand buyers (0–1) | +| `leads` | Leads generated (convenience alias for `by_event_type` where `event_type='lead'`) | +| `by_event_type` | Conversions by event type — array of `{ event_type, count, value?, event_source_id? }` | +| `by_action_source` | Conversions by action source (website, app, in_store) — array of `{ action_source, count, value?, event_source_id? }` | +| `dooh_metrics` | DOOH-specific data — object with `loop_plays`, `screens_used`, `screen_time_seconds`, `sov_achieved`, `venue_breakdown` | +| `ooh_metrics` | Classic (static) OOH data (experimental in 3.2) — object with `panels` (multi-identifier), `posting_period_start`/`posting_period_end`, `estimated_impressions`, `estimation_basis`, `postings` (posting records with photo evidence) | +| `viewability` | Viewability data — object with `vendor`, `measurable_impressions`, `viewable_impressions`, `viewable_rate`, `viewed_seconds` (average in-view duration per measurable impression — pairs with the `viewed_seconds` optimization goal), and `standard`. `standard` SHOULD be present whenever measured viewability values are reported | +| `engagements` | Total ad engagements beyond viewing (platform-specific) | +| `follows` | New followers or subscriptions attributed to delivery | +| `saves` | Saves, bookmarks, or pins attributed to delivery | +| `profile_visits` | In-platform page visits attributed to delivery | +| `engagement_rate` | Platform-specific engagement rate (engagements/impressions) | +| `vendor_metric_values` | Vendor-attested metrics (viewability, attention, etc.) — one entry per vendor and metric identifier | + +> **Spend-derived metrics.** Sellers should not populate `roas` and `cost_per_acquisition` on individual `variant` objects — spend cannot be attributed per-variant since it applies to the creative as a whole. These fields are meaningful only on `creative.totals`. `by_event_type` entries carry `count` and `value` per event type, but not spend-derived rates. + +> **Platform-conditional fields.** `dooh_metrics` is present only for DOOH campaigns; `ooh_metrics` only for static OOH campaigns. Engagement fields (`engagements`, `follows`, `saves`, `profile_visits`, `engagement_rate`) are platform-specific; not all sellers emit them on standardized fields — some use `variant.ext` for engagement data instead. + +### Variant Object + +Each variant row represents a specific execution within one source revision: 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` | Agent-assigned served-execution identifier. Existing agents retain uniqueness within agent + creative. An agent advertising `creative.supports_revisions` uses an agent-unique immutable value, which is the unambiguous lookup key when it also supports variant preview. | +| `revision_id` | Buyer-authored input revision from which this execution was derived. Historical rows may name an older revision than the creative currently returned by [`list_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives). | +| `locale_variant_id` | Buyer-assigned localized variant that supplied the served assets. Required for delivered variants of a localized creative, including default fallback; omitted for unlocalized creatives. | +| `manifest` | (Optional) The rendered canonical creative manifest—the actual output served, with `format_kind` and resolved assets. | +| `generation_context` | (Optional, Tier 3) Input signals that triggered generation — e.g., page topic, conversation theme, query category. Platforms provide summarized/anonymized signals, not raw user input. When content context is managed through AdCP content standards, includes an `artifact` reference linking to the specific content artifact. Supports `ext` for vendor-specific context structures. | +| `ext` | (Optional) Platform-specific data. Social platforms use this for engagement metrics (upvotes, comments, shares) that vary by platform. | +| Standard metrics | All [Delivery metrics fields](#delivery-metrics-fields) — same shape as `creative.totals` | + +`creative_id`, `revision_id`, and `variant_id` are separate namespaces. The +canonical build-to-delivery join is `build_creative.variants[].build_variant_id` +→ promoted `creative_id` → delivery `creative_id`. `revision_id` identifies the +immutable buyer-authored input state beneath that creative; for a seller +advertising `creative.supports_revisions`, `variant_id` is an agent-unique AdCP +served-execution identity. Every execution derived from +a revision carries that revision even when the seller transcodes or chooses +another equivalent delivery representation. + +### Revision attribution + +Do not compare every row to the creative's current `list_creatives.revision_id`. +A reporting window may contain executions from several accepted revisions; each +row carries the source revision that actually produced that execution. + +Every row is revision-homogeneous: all of its metrics come from exactly one +source revision and, when localized, one locale. For an agent advertising +`creative.supports_revisions`, `variant_id` is agent-unique and MUST NOT be +reused for another creative, revision, locale, or rendered manifest; this keeps +[`preview_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative) replay by +`variant_id` unambiguous when variant preview is supported. An adapter +whose native ad platform reuses an identifier maps each distinct execution to +a distinct AdCP `variant_id` and may retain the native identifier in `ext`. +`variant_count` counts distinct `variant_id` values before applying +`max_variants`. A fixed, single-size Tier 1 creative therefore has one row per +revision and locale represented in the requested period. A multi-size source +can produce distinct rendered-size executions within the same revision and +locale; those executions have distinct rows, whose resolved `manifest` records +the actual dimensions. Selecting a size does not change the source revision. A seller +that cannot partition delivery this way cannot advertise +`creative.supports_revisions: true`. + +For localized delivery, `locale_variant_id` is checked against the source +revision's accepted localization topology, not only the current +`list_creatives` topology. + +### Localized delivery attribution + +For a localized creative, every returned execution MUST include +`locale_variant_id` and that value MUST exist in the accepted localization +topology of the row's source revision. When that revision is current, the value +therefore also exists in `list_creatives.localization.variants[]`; historical +rows are checked against buyer-retained sync state rather than a newer current +topology. This identifies the assets that +actually served after strict RFC 4647 Lookup, an explicit locale fallback, or +the default action and is required for every such path. When the selected +product format carries `locale_policy`, this value MUST also identify a variant +eligible under its accepted language ranges; buyer fallbacks and defaults never +bypass that seller constraint. `variant_id` remains the seller execution identity; +`locale_variant_id` is the buyer's stable locale identity. + +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 + +Within one source revision and locale, a fixed, single-size standard creative +maps 1:1 to one variant. A multi-size source can produce more than one variant, +one for each distinct rendered-size execution. A report spanning revision, +locale, or rendered-output boundaries contains distinct variant rows; a row's +metrics match the creative totals only when it is the sole row in the reporting +period. + +```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_kind": "image", + "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_kind": "image", + "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_kind": "image", + "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_kind": "image", + "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`](/dist/docs/3.3.0-beta.3/creative/task-reference/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`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/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.3.0-beta.3/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 row scope**: The published default scopes `variant_id` to agent + creative. An agent advertising `creative.supports_revisions` strengthens this to agent-unique and immutable. In either case, use the structured tuple `(agent_url, media_buy_id-or-null, creative_id, variant_id)` as the globally scoped reporting row key; `revision_id` and `locale_variant_id` are immutable attribution fields, not extensions of variant identity. +- **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. **Build a variant-row key**: Serialize the structured tuple `agent_url + media_buy_id-or-null + creative_id + variant_id`. Use actual `null`, not string sentinels, so legitimate IDs cannot collide with absence. Distinct revisions and locales already have distinct agent-assigned `variant_id` values. + +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`](/dist/docs/3.3.0-beta.3/creative/task-reference/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_row_id: JSON.stringify([ + agents[i].url, + creative.media_buy_id ?? result.media_buy_id ?? null, + creative.creative_id, + v.variant_id, + ]), + }); + } + } +} +// 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 expose `get_creative_delivery` in their tool surface and declare Creative Protocol support through [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities): + +```json +{ + "supported_protocols": ["creative"], + "creative": { + "has_creative_library": true, + "supports_revisions": true + } +} +``` + +Buyers discover candidate creative agents through the registry, then confirm the live endpoint's tool and `get_adcp_capabilities` surfaces. This applies to any endpoint implementing the Creative Protocol, including a sales agent with creative capabilities. diff --git a/dist/docs/3.3.0-beta.3/creative/task-reference/list_creative_formats.mdx b/dist/docs/3.3.0-beta.3/creative/task-reference/list_creative_formats.mdx new file mode 100644 index 00000000000..ca797c49d31 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/task-reference/list_creative_formats.mdx @@ -0,0 +1,78 @@ +--- +title: list_creative_formats (deprecated) +description: "Deprecated AdCP 3.x named-format discovery task and its canonical 3.2 replacements." +"og:title": "AdCP — list_creative_formats (deprecated)" +testable: true +--- + +`list_creative_formats` is deprecated in AdCP 3.2. It combined three different questions into one agent-global list and therefore could not accurately describe publisher acceptance, product deliverability, and creative production at the same time. + +Existing 3.x agents MAY continue serving the task for compatibility. New implementations MUST use the canonical surfaces below and SHOULD derive any legacy response mechanically from canonical declarations rather than maintaining a second catalog. The task follows its own compatibility schedule; removal of exact plural `format_ids` fields in AdCP 4.0 does not itself remove this task. + +## Use the surface that owns the fact + +| Question | Canonical source | +|---|---| +| What formats does a publisher declare? | The publisher's `/.well-known/adagents.json` `formats[]`, with AgenticAdvertising.org community-mirror fallback when the publisher has not adopted | +| What formats can this sales agent deliver for a product? | [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products) → `Product.format_options[]` | +| What can this creative agent build, validate, or preview? | [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) → `creative.supported_formats[]` | + +Sales agents MUST NOT use an agent-global format list as a purchasability promise. Product `format_options[]` is the closed sellable set, and placement declarations may narrow it. + +Creative agents advertise stable build routing separately from publisher/product format identity: + +```json +{ + "creative": { + "supported_formats": [ + { + "capability_id": "streamhaus_vertical_video", + "operations": ["build", "validate", "preview"], + "format": { + "publisher_domain": "shorts.streamhaus.example", + "format_option_id": "vertical_video", + "format_kind": "video_hosted", + "params": { + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 60000] + } + } + } + ], + "preview": { + "routes": [{ + "capability_id": "streamhaus_vertical_video", + "rendering_origin": "agent_approximation" + }] + } + } +} +``` + +The buyer passes `streamhaus_vertical_video` as `target_capability_id` to [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative). The returned manifest carries canonical `format_kind: "video_hosted"`; capability IDs never appear on products or manifests. + +## Registry reverse discovery + +The AgenticAdvertising.org registry indexes canonical creative capabilities. To find creative agents that claim they can build an exact publisher format: + +```http +GET /api/registry/agents?publisher_domain=shorts.streamhaus.example&format_option_id=vertical_video&creative_operation=build +``` + +Generic agents can advertise a canonical parameter envelope without publisher identity. A match is valid only when the capability can satisfy every target constraint—not merely because both declarations share the same `format_kind`. + +## Compatibility behavior + +The deprecated request and response schemas remain available at: + +- [`/schemas/3.3.0-beta.3/media-buy/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/media-buy/list-creative-formats-request.json) +- [`/schemas/3.3.0-beta.3/media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/media-buy/list-creative-formats-response.json) + +Compatibility implementations SHOULD: + +1. Keep canonical declarations as the only authored source. +2. Project named formats only for a peer that still requires them. +3. Never infer a publisher catalog from one seller's products. +4. Never return publisher-declared formats as if every format were deliverable by the responding sales agent. + +See [Canonical formats](/dist/docs/3.3.0-beta.3/creative/canonical-formats) for the complete matching and ownership model. diff --git a/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives.mdx b/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives.mdx new file mode 100644 index 00000000000..d3b393c5e59 --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/task-reference/list_creatives.mdx @@ -0,0 +1,629 @@ +--- +title: list_creatives +description: "list_creatives browses and filters creatives in an AdCP library by asset type, 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 asset type, format, status, concept, tags, date range, and dynamic variables, with pagination and optional field enrichment. + +For every account the caller can access, the library membership is +**source-neutral**: sellers MUST return creatives the caller can see regardless +of whether they were created through [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives), a seller UI or API, +seller automation, another authorized principal, or a connected platform. +Filters and lifecycle defaults still apply. An implementation that exposes +only AdCP-created creatives is not a conforming account snapshot. + +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 asset type, 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 +- Find creatives with seller indicators such as package-scoped creative fatigue +- Reconcile source-neutral external changes through the optional, experimental [account change feed](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes) + +## 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.3.0-beta.3/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) | +| `assignment_projection` | string | No | `all` (default) returns active assignments; `matching` returns only assignments matching `filters.indicator_types` and requires that filter. | +| `assignment_limit` | integer | No | Maximum nested assignment rows per creative (default: 50, maximum: 200). | +| `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.3.0-beta.3/creative/task-reference/get_creative_delivery). | +| `include_items` | boolean | No | Include items for multi-asset formats like carousels and native ads (default: false) | +| `include_variables` | boolean | No | Include dynamic content variable definitions (default: false) | +| `include_pricing` | boolean | No | Include `pricing_options` on each creative (default: false). Requires `account`. | +| `include_purged` | boolean | No | Include soft-purged creative tombstones (default: false). See [Purged tombstones](#purged-tombstones). | +| `include_webhook_activity` | boolean | No | Include recent webhook fires per creative (default: false). See [Webhook activity](#webhook-activity). | +| `webhook_activity_limit` | integer | No | Maximum `webhook_activity[]` records per creative when `include_webhook_activity: true` (default: 50, range 1–200). | +| `account` | [AccountRef](/dist/docs/3.3.0-beta.3/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 optional fields to return (omit for all fields). Every row still includes the released base envelope: `creative_id`, `name`, `status`, `created_date`, `updated_date`, and exactly one of `format_id` or `format_kind`. Optional selections include `"assignments"`, `"assets"`, `"pricing_options"`, the experimental `"staged_update"` (which also returns `status`), and `"rights"` / `"rights_attestation_evaluations"`; selecting rights evaluations automatically includes `rights`. | + +## Filtering options + +The `filters` object supports these optional, composable filters: + +| Filter | Type | Description | +|--------|------|-------------| +| `accounts` | [AccountRef](/dist/docs/3.3.0-beta.3/building/by-layer/L2/accounts-and-agents#account-references)[] | Filter by owning accounts | +| `format_kinds` | CanonicalFormatKind[] | Filter by canonical format kinds | +| `format_ids` | FormatID[] | Deprecated named-format compatibility filter | +| `asset_types` | AssetContentType[] | Filter by asset types directly assigned to top-level creative asset slots; values within the array use OR logic | +| `statuses` | [CreativeStatus](/dist/docs/3.3.0-beta.3/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 * | +| `indicator_types` | `IndicatorType[]` | Filter for creatives with at least one package assignment carrying any requested current indicator type. See the [standard indicator catalog](/dist/docs/3.3.0-beta.3/media-buy/media-buys/indicators#standard-indicator-catalog). * | + +\* Assignment-related filters are specific to sales agents. Standalone creative agents ignore these. + +### Asset-type matching + +`asset_types` matches a creative when at least one direct object value in the top-level `creative.assets` map has an exact `asset_type` value in the requested set. Multiple requested values use **OR logic**. Array-valued slots are not inspected, and matching does not recurse into nested asset fields such as `cards[].media`; broader traversal is deferred. + +All active filter fields compose with **AND logic**. For example, the following request returns published-post creatives that also match the requested canonical format kind; a creative satisfying only one condition is excluded: + +```json +{ + "filters": { + "asset_types": ["published_post"], + "format_kinds": ["native_in_feed"] + } +} +``` + +For deterministic status and assignment filters, every returned creative MUST +satisfy the supplied predicates: `status` MUST be a member of `statuses`, and a +sales agent applying `media_buy_ids` MUST return only creatives with an +assignment to at least one requested media buy. These predicates remain in force +across every page and compose with each other and with all other active filters. +A seller MUST NOT accept them and return the unfiltered library. + +A filtered request can validly return the same rows as an unfiltered request +when every visible creative already matches. Conformance is established from +status and assignment membership—not by requiring the two response payloads to +differ. Standalone creative agents retain the documented exception for +sales-agent-specific assignment filters. + +Use `asset_types: ["published_post"]` to find existing-published-post reference creatives across sellers without enumerating publisher-specific format options, or `asset_types: ["zip"]` to find HTML5 bundle archives. Values such as `url`, `text`, and `image` are common companion assets, so they often produce broad result sets when used alone. + +Agents that do not implement `asset_types` MUST ignore that field and apply all other active filters. They must not reject the request solely because the filter is unsupported. Because unsupported agents deliberately over-return, buyers that require an exact filtered result MUST retain a local fallback: request `assets` (or omit `fields`), traverse every page until `pagination.has_more` is false, and locally inspect the returned top-level asset objects. When a seller returns `query_summary.filters_applied`, buyers can verify that it includes `asset_types`; absence means the local fallback is required. + + +**Archived creatives are excluded by default.** To include archived creatives in results, explicitly include `"archived"` in the `statuses` array. Suspended creatives are not archived; include `"suspended"` when you specifically want recoverably offline creatives such as published-post references with expired authorization. + + + +For published-post reference products, `list_creatives` is limited to the downstream publisher identities the seller is authorized to inspect. If a product requires a second platform connection such as `publisher_identity` and it is missing, the seller should return [`AUTHORIZATION_REQUIRED`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-authorization-required) with `error.details.missing_connections[]`. It should not present `list_creatives` as global platform post search. + + + +## Sorting options + +Sort results by various fields with ascending or descending order: + +```json +{ + "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.3.0-beta.3/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", + "platform_id": "cr_400918", + "revision_id": "rev_2026_08_23_01", + "seller_revision": 7, + "name": "Holiday Sale - Medium Rectangle", + "format_kind": "image", + "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 + } +} +``` + +When the seller advertises `creative.supports_revisions: true`, `revision_id` +is the current buyer-authored input revision for that creative. It is exact +readback of the value accepted by [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives), not a seller processing or +transcode version. Older creatives without revision identity may omit it. + +### Seller revision + +`seller_revision` is the seller's own change token for the creative, added in +3.3. It is separate from `revision_id`: `revision_id` is assigned by the buyer +and names the input content the buyer sent, while `seller_revision` is +assigned by the seller and changes whenever the state this read reports +changes. Sellers MUST NOT mint a `revision_id`, and a `revision_id` never +substitutes for `seller_revision`. + +A seller that returns it MUST advance it on every committed change to state +`list_creatives` reports, whatever its origin: an AdCP task, a seller operator, +seller automation such as a review decision, another authorized principal, or a +connected platform. An externally observed change advances it when the seller +ingests it, within the source's declared freshness bound. Reads, validation-only +calls and exact idempotency replays do not advance it, and a `sync_creatives` +item reported `unchanged` does not either. The value never repeats for a +different state of the same creative and never moves backward. + +Buyers treat the value as opaque. Equal values mean the same state as the seller has observed; a +different value means the creative changed. Integer values also order +numerically. When a drained [`account.change`](/dist/docs/3.3.0-beta.3/accounts/tasks/list_account_changes) record for the creative carries +`resource_revision`, it is the value immediately after that change. A read that +returns the same value reflects the change; a different value reflects later +changes too. The field is optional: a seller that does +not maintain it omits it and the buyer falls back to rereading. + +### Provenance readback + +`provenance` echoes the creative-level [provenance](/dist/docs/3.3.0-beta.3/creative/provenance) the seller accepted on [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives) for the returned revision, so a reviewer can see AI-disclosure metadata without a separate lookup. It is a read-only echo of the buyer's claim, not seller verification, and not a second source of truth. It is the creative-level default only: an asset's own `provenance` inside `assets` still replaces it entirely (no field-level merging). This adds no new revision rule. Provenance is not among the fields excluded from [canonical revision content](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives#creative-revisions), so it stays part of revision identity as it is today, and a change to it needs a new `revision_id`. The seller returns the buyer-submitted object unaltered and MUST NOT add or change members; seller-side verification is not part of this echo. Sellers that persist creative-level provenance SHOULD echo it; sellers that do not persist it omit the field, but the revision fingerprint still covers submitted provenance, so a same-`revision_id` resync with changed provenance is a [`CREATIVE_REVISION_CONTENT_MISMATCH`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-creative-revision-content-mismatch) either way. Buyers should select it through `fields` only against sellers on a release that defines it. Select it with `fields: ["provenance"]`. [`get_creative_delivery`](/dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery) does not carry provenance. + +### Per-creative fields + +| Field | Type | Description | +|-------|------|-------------| +| `creative_id` | string | Unique creative identifier. Assigned by the seller for creatives created outside AdCP. | +| `platform_id` | string | The seller's identifier for the creative in its source of truth. Same value `sync_creatives` returns. | +| `revision_id` | string | Current buyer-authored input revision when the current content is revision-bound. Omitted for legacy/unversioned current content. | +| `seller_revision` | integer or string | Optional seller-maintained, monotonic, opaque change token, distinct from `revision_id`. Advances on every committed change to the creative from any origin. See [Seller revision](#seller-revision). Added in 3.3. | +| `name` | string | Human-readable name | +| `format_kind` | string | Canonical format kind | +| `format_option_ref` | object | Optional exact publisher/product option reference | +| `format_id` | object | Deprecated 3.x named-format compatibility field | +| `status` | string | Approval status | +| `staged_update` | object | Experimental. A change accepted in `staged` mode and not yet promoted. While present, `assets`, `revision_id`, and `status` describe the version that serves. See [Staged update readback](#staged-update-readback). | +| `status_as_of` | string | When the seller last refreshed `status` from its source of truth. Optional; omission makes no freshness assertion. A value earlier than your last accepted write for this creative means the seller has not yet confirmed that write. See [creative state freshness](/dist/docs/3.3.0-beta.3/media-buy/specification#creative-state-freshness). | +| `created_date` | string | Creation timestamp | +| `updated_date` | string | Last modified timestamp | +| `assets` | object | Creative assets (images, text, URLs, etc.) | +| `localization` | object | Exact materialized locale assets, buyer-assigned variant identities, and default/unmatched policy for localized creatives. | +| `localization_unavailable` | object | Fail-closed per-item errors and retryability when exact localization readback cannot be constructed. Mutually exclusive with `localization`. | +| `provenance` | object | Read-only echo of the creative-level [provenance](/dist/docs/3.3.0-beta.3/creative/provenance) accepted on `sync_creatives` for the returned revision. Omitted when none was declared. | +| `tags` | string[] | Tags for categorization | +| `rights` | array | Exact retained rights constraints and portable references. Presentation only; not proof of seller verification. | +| `rights_attestation_evaluations` | array | Seller-produced, freshness-bounded grant evaluations. Each reference must exactly match one reference in the returned `rights` array. | +| `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.3.0-beta.3/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`](/dist/docs/3.3.0-beta.3/signals/tasks/get_signals) and [`list_content_standards`](/dist/docs/3.3.0-beta.3/governance/content-standards/tasks/list_content_standards). | + +### Staged update readback + +Experimental in 3.3, with [`creative.live_updates`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities#creative). + +This row tracks a staged update. To learn what a seller does with a change, read the `creative.live_updates` capability, which is the general floor, or send the change with [`dry_run: true`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives#live-creative-updates) for the exact answer to one change. To be told when review decides a staged update instead of polling, register `creative.staged_update_changed` on a `notification_configs[]` entry. + +`staged_update` is present while a change accepted in `staged` mode awaits promotion. The rest of the row describes the version that serves: `assets`, `revision_id`, and `status` stay those of the prior approved version until the seller promotes the update. On promotion the row's content and `revision_id` become the staged ones and `staged_update` is removed. Under `on_rejection: "keeps_prior"`, a rejected update stays as `staged_update.status: "rejected"` with its `rejection_reason` until the buyer supersedes or withdraws it. + +```json +{ + "creative_id": "acme_hero_video", + "revision_id": "rev_2026_10_01", + "name": "Acme hero video", + "format_kind": "video", + "status": "approved", + "created_date": "2026-10-01T12:00:00Z", + "updated_date": "2026-11-02T15:04:00Z", + "staged_update": { + "status": "pending_review", + "revision_id": "rev_2026_11_02", + "change_kinds": ["asset_content"], + "staged_at": "2026-11-02T15:04:00Z" + } +} +``` + +### Creatives created outside AdCP + +A creative can enter the seller's library without `sync_creatives`: through a seller UI or API, seller automation, or another system. That happens, for example, when a buyer connects to an advertiser that already has live buys and creatives. Those creatives have no buyer-assigned ID, so the seller assigns their `creative_id`, and the buyer uses it to reference them in later requests. + +- Sellers SHOULD keep that ID stable for the creative's lifetime and unique within the account, and SHOULD use UUIDs or a stable prefix (for example, `slr_`) so it does not collide with buyer-chosen IDs. It MAY equal `platform_id`. +- A seller that declares [`creative.stable_creative_ids`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities#creative) MUST keep it stable and unique, and MUST NOT reuse a `creative_id` a buyer has used on the account. Buyers MUST NOT rely on a seller-assigned ID staying the same unless the seller declares it. +- Sellers MUST NOT mint a `revision_id` for such a creative. `revision_id` is buyer-authored; it appears only after a buyer supplies one through `sync_creatives`. + +To manage one of these creatives, a buyer uses its `creative_id` as-is. See [seller-assigned creative IDs](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives#seller-assigned-creative-ids). A buyer tells its own creatives apart by the IDs it synced. + +`platform_id` is the seller's identifier for the creative in its source of truth, the same value `sync_creatives` returns. It lets operators match a creative to the seller's own UI and reports without keeping the sync response. Buyers address creatives by `creative_id`, never by `platform_id`. + +### Assignment indicators + +In AdCP 3.3, sellers advertising `creative.supports_assignment_trackers` MUST return `assignments.assigned_packages[].trackers`: the complete assignment-scoped tracker list, or `[]` when none are attached. Preserve event names, URL templates, order, and duplicate occurrences. The list excludes the creative's own trackers in `assets`; both sets fire on this placement. See [assignment trackers](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives#assignment-trackers). + +In AdCP 3.3, `assignments.assigned_packages[].landing_page_url` reports the effective serving destination for that creative on that package. Sellers advertising `creative.supports_assignment_landing_page` return the assignment override, or the creative's own destination when unique. Macro-bearing URLs retain the effective template. Omit the scalar when no destination exists or an unoverridden creative has multiple destinations; read those from the creative's `assets` rather than treating an arbitrary destination as applying to every click. The creative's own `status` and assignment `approval_status` remain separate. See [assignment landing pages](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives#assignment-landing-pages). + +Creative-library sellers that advertise `list_creatives` in `media_buy.relationship_notifications.projection_tasks` attach evaluated indicator state to `assignments.assigned_packages[]` and include `media_buy_id` plus `approval_status` on every row, including unknown rows. These fields mirror [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys) for the same relationship. A mixed publisher/placement outcome uses `approval_status: "partially_approved"` plus complete `approval_scopes[]`; the assignment webhook remains a compact invalidation and buyers reread these scopes. Each evaluated row includes `indicator_types_evaluated`, `indicators_as_of`, and `indicators[]`; omitted types remain unknown. + +An assignment row MAY carry `approval_status_as_of`: when the seller last refreshed `approval_status`, `rejection_reason`, and `approval_scopes` for that relationship from its source of truth. It follows the same rules as `creative_approvals[].approval_status_as_of` on `get_media_buys`. When both reads carry it for the same relationship, the values agree. See [creative state freshness](/dist/docs/3.3.0-beta.3/media-buy/specification#creative-state-freshness). + +```json +{ + "include_assignments": true, + "filters": { + "indicator_types": ["creative_fatigue"] + }, + "fields": ["creative_id", "assignments"], + "assignment_projection": "matching", + "assignment_limit": 50, + "pagination": { + "max_results": 100 + } +} +``` + +This request limits optional payload to assignments while retaining each row's released base envelope (`creative_id`, `name`, `status`, dates, and one format identity). `assignment_count` remains the total active relationship count; `returned_assignment_count`, optional `matching_assignment_count`, and `assignments_truncated` describe the bounded nested projection. When truncated, use `get_media_buys` as the complete repair path. + +The standard indicator shape intentionally contains no indicator ID, source envelope, lifecycle status, or sub-version. The responding seller is the source; AdCP defines the broad meaning of the type for the negotiated protocol release. Seller-specific scores, methodology, evaluation windows, and upstream attribution belong in `ext`. + +Omitted `indicators` means unknown. An empty array is evaluated-clear only for `indicator_types_evaluated` and declared coverage. Do not infer clearing from filtered/paginated disappearance; directly reread without `indicator_types`. See [Indicators and Warnings](/dist/docs/3.3.0-beta.3/media-buy/media-buys/indicators). + +### Rights attestation readback + +Selecting `fields: ["rights_attestation_evaluations"]` automatically includes `creative_id` and `rights`. Every evaluation's `{rights_id, content_digest, reference}` must select exactly one retained constraint and one byte-for-byte equal `attestation_refs` entry. The evaluation array is complete rather than independently truncated: because `rights` has no item ceiling, neither does its evaluation readback. A `verified` result is reusable only when the seller retrieves the byte-identical result from its own local evaluation store keyed by `(evaluated_by, reference_digest)`, for that exact unchanged grant, and only until `valid_until`. The `evaluated_by` string alone is not provenance; buyer-authored and foreign-seller readback fails closed. The result is not a serving authorization and does not replace use, country, time, package, or impression-cap checks. When the seller advertises `media_buy.rights_attestations.requirement: "required"`, every applicable constraint on a serving-eligible creative needs at least one current verified result. + +### Localized creative readback + +For a localized creative, top-level `creative_id` identifies the creative and +`localization` is the authoritative locale-topology round trip. The localization +object lists exactly one source plus every materialized target variant; +source-only monolingual topology therefore contains one variant. It preserves +the originating request's exact source ID/locale, target ID/locale set, default +ID, unmatched action, and any explicit locale fallback rules. Each entry +returns complete resolved assets under its buyer-assigned `locale_variant_id`; no seller- or +platform-assigned variant ID is required. The top-level creative `status` is the +single review lifecycle for the complete locale set. + +Verifiers enforce unique locale and buyer locale-variant ID values, exactly one +source role, a default ID that references one variant, unique fallback language +ranges whose IDs reference stored variants, and +equality between source readback assets and top-level creative assets. +These rules are machine-readable in `x-adcp-validation` because draft-07 +`uniqueItems` compares whole objects and cannot express uniqueness or reference +integrity by property. + +This object is atomic. If the seller omits a requested locale, normalizes it +to a different locale, or loses an asset or identity, the seller returns +`localization_unavailable` instead of a partial `localization` object. The item +remains in the current page and counts toward `query_summary.returned` and +pagination. Buyers may use its base metadata but MUST NOT infer locale +eligibility. Selecting `fields: ["localization"]` automatically includes the +creative ID, status, assets, format identity, and unavailable state +needed to interpret this result. + +At delivery, sellers apply strict RFC 4647 Lookup: progressively truncate the +requested range and match only an equal seller-eligible canonical tag. A +selected product or placement `locale_policy` first filters this readback with +RFC 4647 Basic Filtering; it never mutates or removes the globally stored +variants. When no variant matches for a preference, sellers apply the most-specific explicit +`locale_fallbacks` rule for that preference before moving to the next one. When +all preferences miss both paths, they follow `unmatched_locale_action`: serve +`default_locale_variant_id` or do not serve. +The ordered preference list itself comes from the seller's serving environment; +AdCP neither carries it here nor defines how browser, content, geography, user, +app, or platform signals produce it or their precedence. +The chosen `locale_variant_id` is reported by [`get_creative_delivery`](/dist/docs/3.3.0-beta.3/creative/task-reference/get_creative_delivery). + +### 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`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative) response) in [`report_usage`](/dist/docs/3.3.0-beta.3/accounts/tasks/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.3.0-beta.3/signals/tasks/get_signals) and [content standards](/dist/docs/3.3.0-beta.3/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.3.0-beta.3/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. | + +### Purged tombstones + +When a creative is destroyed via [`creative.purged`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/creative/creative-purged-webhook.json) with `purge_kind: soft`, the seller retains a tombstone for 30 days from the purge timestamp. The tombstone surfaces on `list_creatives` only when the request sets `include_purged: true`: + +```json +{ + "creative_id": "ft_87100", + "name": "Holiday Sale - Leaderboard (purged)", + "status": "approved", + "purge": { + "kind": "soft", + "at": "2026-05-18T02:59:48Z", + "reason_code": "retention_expired" + } +} +``` + +The `status` field on a tombstone is **frozen at its pre-purge value** (in the example above, `"approved"` is what the creative was right before purge — not a current claim). Buyers MUST treat the creative as gone: assignments, serving operations, and delivery reads no longer apply. The presence of the `purge` block is the unambiguous signal. + +Hard-purged creatives (`purge_kind: hard`, used for legal erasure under GDPR Article 17 / CCPA / equivalent) retain no tombstone; the [`creative.purged`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/creative/creative-purged-webhook.json) webhook is the only signal. See the [snapshot-and-log conforming-pairs catalog](/dist/docs/3.3.0-beta.3/protocol/snapshot-and-log#current-conforming-pairs) for the recovery exclusion. + +### Webhook activity + +When `include_webhook_activity: true`, each returned creative carries a `webhook_activity[]` array of the most recent fires scoped to that creative — `creative.status_changed`, `creative.staged_update_changed`, `creative.purged`, `creative.assignment_changed`, and assignment-level `indicators.changed` deliveries. This is the buyer's debug surface for "did the publisher fire? did my endpoint receive it? was the retry trail clean?" — the same shape and contract as `webhook_activity[]` on [`get_media_buys`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_media_buys). See [Webhook activity log pattern](/dist/docs/3.3.0-beta.3/protocol/snapshot-and-log#webhook-activity-log-pattern) for the full normative contract (retention, three-state presence, request-field conventions). + +**To subscribe** when the seller declares `media_buy.relationship_notifications`, register a [`notification_configs[]`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts#account-level-webhook-subscriptions) entry on the account via [`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts). The seller fires per-subscriber against each entry whose `event_types[]` includes the type. Indicator support itself may be poll-only; push-capable sellers may advertise `indicators.changed` and independently advertise `creative.assignment_changed`. Subscriptions are prospective, so establish a complete all-status or known-ID baseline through `get_media_buys` after activation. Dedupe later invalidations and reread `get_media_buys` rather than applying payload state; `webhook_activity[]` on this read is a bounded reverse projection and debug log, not proof of complete repair. + +Three-state presence applies: + +- **Field omitted** — seller does not surface webhook activity on this read. +- **`[]`** — seller surfaces the field but no fires fall in the retention window for this creative. +- **Non-empty** — actual records, most recent first, capped at `webhook_activity_limit` (max 200). + +Correlate to your endpoint logs by `idempotency_key`. Each record's `notification_type` discriminates the fire kind: + +```json +{ + "webhook_activity": [ + { + "idempotency_key": "whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", + "notification_id": "cs_ft88201_2026_05_18a", + "subscriber_id": "buyer-primary", + "fired_at": "2026-05-18T14:20:00Z", + "completed_at": "2026-05-18T14:20:00Z", + "notification_type": "creative.status_changed", + "attempt": 1, + "status": "success", + "url": "https://buyer.example/webhooks/adcp/creative", + "http_status_code": 200, + "response_time_ms": 142, + "payload_size_bytes": 612, + "error_message": null + } + ] +} +``` + +Buyers diagnose "no fires arrived" by combining: (a) subscriber registration state on `list_accounts.accounts[].notification_configs[]` — is the right URL active with the right `event_types[]`? — and (b) seller capability declaration via [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) — does the seller support the event types I subscribed to? Field omission on `webhook_activity` alone does not distinguish "seller doesn't surface the log" from "no fires occurred." + +### Buyer handler (end-to-end) + +A buyer's webhook handler for `creative.status_changed` correlates each fire with library state via `creative_id`, dedupes on `idempotency_key`, and re-reads `list_creatives` for the authoritative snapshot (per [snapshot-and-log Rule 3](/dist/docs/3.3.0-beta.3/protocol/snapshot-and-log#the-five-rules)): + +```javascript +// POST /webhooks/adcp/creative +async function handleCreativeWebhook(req, res) { + // 1. Verify signature per the registered scheme (RFC 9421 by default). + if (!verifyWebhookSignature(req)) return res.status(401).end(); + + const fire = req.body; // creative-status-changed-webhook or creative-purged-webhook + const { notification_type, idempotency_key, creative_id, account_id } = fire; + + // 2. Dedupe at-least-once delivery. + if (await alreadyProcessed(idempotency_key)) return res.status(200).end(); + + // 3. Re-read snapshot for authoritative state. Push is signal; snapshot is truth. + const snapshot = await testAgent.listCreatives({ + filters: { creative_ids: [creative_id] }, + include_purged: notification_type === "creative.purged" + }); + + // 4. Apply local effects from the snapshot, not the webhook payload. + await reconcileCreative(snapshot.creatives[0]); + + await markProcessed(idempotency_key); + return res.status(200).end(); +} +``` + +Two pitfalls this handler avoids: (1) applying state from the webhook payload directly (ordering and re-emission make the payload non-authoritative); (2) skipping dedup (sellers retry on non-2xx and re-emit on missed-events warnings). + +## Account requirements + + +Creative agents that host a library should implement the [accounts protocol](/dist/docs/3.3.0-beta.3/accounts/overview) ([`sync_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/sync_accounts) / [`list_accounts`](/dist/docs/3.3.0-beta.3/accounts/tasks/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 canonical image creatives across concepts: + +```json +{ + "filters": { + "format_kinds": ["image"], + "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_kind", "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.3.0-beta.3/creative/task-reference/get_creative_delivery) - Detailed performance analytics with date ranges, variant breakdowns, and full delivery metrics +- [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative) - Build manifests from library creatives or generate from scratch +- [`sync_creatives`](/dist/docs/3.3.0-beta.3/creative/task-reference/sync_creatives) - Upload and manage creative assets on any agent hosting a creative library +- [Canonical formats](/dist/docs/3.3.0-beta.3/creative/canonical-formats) - Discover product, publisher, and creative-agent format declarations +- [`preview_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative) - Generate previews of creative manifests diff --git a/dist/docs/3.3.0-beta.3/creative/task-reference/list_transformers.mdx b/dist/docs/3.3.0-beta.3/creative/task-reference/list_transformers.mdx new file mode 100644 index 00000000000..1277816711b --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/task-reference/list_transformers.mdx @@ -0,0 +1,141 @@ +--- +title: list_transformers +description: "list_transformers discovers account-scoped creative transformers — the agent-offered, selectable units of build capability (voices, models, styles) used by build_creative." +"og:title": "AdCP — list_transformers" +testable: true +--- + + +Discover the **transformers** a creative agent offers for your account. A transformer is the creative analog of a media-buy product: an agent-offered, account-scoped, selectable unit of build capability (a voice, a model, a style, a director) with a typed configuration surface and per-account pricing. You discover transformers here, then select one with `transformer_id` in [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative). + +**Response Time**: ~1 second (account-scoped lookup) + +**Authentication**: Account-scoped. Transformers, their enumerable option values, and pricing are resolved for the calling credential — including custom values you configured (e.g. cloned voices) that exist only for your account. + +**Request Schema**: [`/schemas/3.3.0-beta.3/creative/list-transformers-request.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/creative/list-transformers-request.json) +**Response Schema**: [`/schemas/3.3.0-beta.3/creative/list-transformers-response.json`](https://adcontextprotocol.org/schemas/3.3.0-beta.3/creative/list-transformers-response.json) + +Offered only by agents that set `creative.supports_transformers: true` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities). + +## Why transformers + +The set of render knobs a creative agent exposes — and their legal values — is **account-specific and dynamic**. Your configured voices aren't a global enum or a list you hold; the *agent* knows them, and the set changes when you add one. So discovery flows agent → buyer, the same way [`get_products`](/dist/docs/3.3.0-beta.3/media-buy/task-reference/get_products) surfaces account-scoped inventory. `list_transformers` is that discovery surface for creative build capability. + +The agent chooses granularity: a distinct voice or model may be its own transformer, or a single transformer may expose `voice`/`model` as an enumerable `config` param. Either way you use the same call — list transformers, expand a param if you want its values. + +## Request Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `transformer_ids` | string[] | No | Return only these specific transformer IDs | +| `input_format_kinds` | string[] | No | Filter to transformers accepting any of these canonical format kinds | +| `output_capability_ids` | string[] | No | Filter to transformers that can produce any of these `creative.supported_formats[].capability_id` values | +| `name_search` | string | No | Search transformers by name (case-insensitive partial match) | +| `brief` | string | No | Natural-language brief to rank/filter transformers and their option values (e.g. "warm female Spanish-language voiceover"). Curates to intent rather than returning the full set. | +| `expand_params` | string[] | No | Param `field` names for which to return the **first page** of account-scoped option **values** inline on `params[].options[]`. Omit for the lean default (descriptors only). | +| `expand_pagination` | object[] | No | Fetch the **next page** of a specific param's options, scoped per `{ transformer_id, field, options_cursor }` (cursor from a prior response's `params[].options_cursor`). Use once you hold a cursor, instead of `expand_params`. | +| `include_pricing` | boolean | No | Include `pricing_options` on each transformer. Requires `account`. | +| `account` | AccountRef | Conditional | Required when `include_pricing` is true. Transformers are account-scoped regardless. | +| `pagination` | object | No | `max_results` and `cursor` (opaque cursor from previous response) | + +### The `expand` mode + +By default the response returns each transformer's param **schemas** with small closed enums inlined (e.g. `mastering_preset`). To get the **values** of an account-scoped enumerable param (e.g. your voices), name it in `expand_params` — that returns the **first page** on `params[].options[]`. When a param's values are truncated, its `params[].options_cursor` is set; fetch the next page by passing `{ transformer_id, field, options_cursor }` in **`expand_pagination`** (each `(transformer, param)` pages independently). Values are brief-filtered — "warm Spanish female voice" narrows a 300-voice catalog to a handful instead of dumping all of them. There is no separate options endpoint; value enumeration (and its pagination) is a mode of this one tool. + +## Response + +| Field | Description | +|-------|-------------| +| `transformers` | Array of transformer descriptors (see below) | +| `errors` | Optional array of task-specific errors and warnings | +| `pagination` | Pagination cursor when more transformers are available | + +Each transformer carries: + +| Field | Description | +|-------|-------------| +| `transformer_id` | Stable id; pass to [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative) `transformer_id` | +| `name` | Human-readable name | +| `input_formats` / `output_capability_ids` | Canonical declarations it accepts and creative capabilities it produces. A `build_creative` target MUST be a subset of `output_capability_ids`. Omitted `input_formats` means it builds from a brief or raw assets. | +| `params` | Config knobs (see [Transformer Param](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/transformer-param.json)): `field`, `type`, `value_source` (`inline`/`range`/`enumerable`/`free_text`), `allowed_values`/`minimum`/`maximum`, `options[]`+`options_cursor` (when expanded), `max_length` (for `free_text`), `default`. `free_text` is an open buyer-authored string (e.g. a `negative_prompt`); a param MUST NOT be a generation-count knob — count rides `max_variants`/`max_creatives`. | +| `pricing_options` | Per-account rate card. `applies_to_output_capability_ids` may price different canonical outputs differently; an unscoped option is the default. | + +See [Transformer schema](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/transformer.json) for the complete object. + +## Common Scenarios + +### Discover voices for a brief, with values + + + +```javascript test=false +import { testAgent } from '@adcp/sdk/testing'; +import { ListTransformersResponseSchema } from '@adcp/sdk'; + +const result = await testAgent.listTransformers({ + account: { account_id: 'acct_acme' }, + brief: 'warm female Spanish-language voiceover', + output_capability_ids: ['audio_vo'], + expand_params: ['voice'], + include_pricing: true, +}); + +const parsed = ListTransformersResponseSchema.parse(result); +for (const t of parsed.transformers) { + console.log(t.transformer_id, t.name); + const voice = t.params?.find((p) => p.field === 'voice'); + for (const opt of voice?.options ?? []) console.log(' voice:', opt.value, opt.metadata); +} +``` + +```python test=false +import asyncio +from adcp.testing import test_agent +from adcp import ListTransformersResponse + +async def main(): + result = await test_agent.list_transformers( + account={"account_id": "acct_acme"}, + brief="warm female Spanish-language voiceover", + output_capability_ids=["audio_vo"], + expand_params=["voice"], + include_pricing=True, + ) + parsed = ListTransformersResponse.model_validate(result) + for t in parsed.transformers: + print(t.transformer_id, t.name) + +asyncio.run(main()) +``` + + + +### Then build with the selected transformer + +Pass the chosen `transformer_id` and a typed `config` (keyed by each param's `field`) to [`build_creative`](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative): + +```json test=false +{ + "transformer_id": "audiostack_voiceover", + "config": { "voice": "isaac", "speaking_rate": 1.1, "mastering_preset": "podcast" }, + "creative_manifest": { "format_kind": "audio_hosted", "assets": { "script": { "asset_type": "text", "content": "Discover the new winter collection." } } }, + "target_capability_id": "audio_vo", + "account": { "account_id": "acct_acme" }, + "idempotency_key": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5" +} +``` + +## Error Handling + +| Code | Meaning | Recovery | +|------|---------|----------| +| [`AUTH_MISSING`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-auth-missing) | `include_pricing` requested without a resolvable account | Supply `account` / authenticate | +| [`INVALID_REQUEST`](/dist/docs/3.3.0-beta.3/building/verification/compliance-catalog#error-code-invalid-request) | Malformed filter or pagination cursor | Correct the request | + +Unknown `expand_params` (a `field` no transformer exposes) are ignored, not an error — the param simply returns no `options`. + +## Learn More + +- [build_creative](/dist/docs/3.3.0-beta.3/creative/task-reference/build_creative) — select a transformer and produce creatives (incl. variants) +- [get_adcp_capabilities](/dist/docs/3.3.0-beta.3/protocol/get_adcp_capabilities) — `creative.supports_transformers` discriminator +- [Vendor pricing](https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/vendor-pricing-option.json) — the `per_unit` rate model diff --git a/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative-advanced.mdx b/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative-advanced.mdx new file mode 100644 index 00000000000..c5fd9a46d8e --- /dev/null +++ b/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative-advanced.mdx @@ -0,0 +1,332 @@ +--- +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.3.0-beta.3/creative/task-reference/preview_creative). + +## Safe preview grids + +Both HTML and URL output contain untrusted content. Use the [empty-sandbox and caller-controlled CSP pattern](/dist/docs/3.3.0-beta.3/creative/task-reference/preview_creative#html-in-a-sandboxed-frame) for every preview, including previews from publisher-designated providers. HTML output still requires an iframe; a grid or a trusted transport does not permit host-DOM insertion. Shadow DOM provides CSS isolation, not a security boundary. + +This illustrative React component uses the `previewSrcdoc` helper from that pattern. The application supplies schema-validated HTML renders; URL renders require an enforceable policy for the cross-origin document, or a new request for HTML output. The JSX examples use application-provided clients, manifests, and review callbacks and are not standalone scripts. + +```tsx test=false +function PreviewFrame({ render }) { + if (render.output_format !== 'html') { + return

Request HTML output for this isolated review frame.

; + } + return ( +