diff --git a/.changeset/standardize-a2a-profile-extension.md b/.changeset/standardize-a2a-profile-extension.md new file mode 100644 index 0000000000..35922fbdcb --- /dev/null +++ b/.changeset/standardize-a2a-profile-extension.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Define the versioned AdCP A2A 1.0 profile extension, including structured { skill, input } invocation, completed-Task mapping for submitted AdCP work, get_task_status polling, and binding vectors. diff --git a/docs.json b/docs.json index f543317f96..88bc88ea58 100644 --- a/docs.json +++ b/docs.json @@ -1579,6 +1579,10 @@ } }, "redirects": [ + { + "source": "/extensions/adcp/v3", + "destination": "/docs/building/by-layer/L0/a2a-profile-extension" + }, { "source": "/docs/intro", "destination": "/dist/docs/3.1.2/intro", diff --git a/docs/building/by-layer/L0/a2a-guide.mdx b/docs/building/by-layer/L0/a2a-guide.mdx index 8e1e0c225b..3f1ff6d125 100644 --- a/docs/building/by-layer/L0/a2a-guide.mdx +++ b/docs/building/by-layer/L0/a2a-guide.mdx @@ -36,20 +36,61 @@ Examples below use **1.0 wire format** (no `kind` field, ProtoJSON enums). For a ## A2A Client Setup -### 1. Initialize A2A Client +Deterministic AdCP invocation on A2A 1.0 uses the [AdCP A2A Profile Extension v3](/docs/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 a2a = new A2AClient({ - endpoint: 'https://adcp.example.com/a2a', - auth: { - type: 'bearer', - token: process.env.ADCP_API_KEY +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(); }, - agent: { - name: "AdCP Media Buyer", - version: "1.0.0" + 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 @@ -57,7 +98,11 @@ const a2a = new A2AClient({ ```javascript // Check available skills const agentCard = await a2a.getAgentCard(); -console.log(agentCard.skills.map(s => s.name)); +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", ...] ``` @@ -66,9 +111,16 @@ console.log(agentCard.skills.map(s => s.name)); ```javascript const response = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [{ - text: "Find video products for pet food campaign" + data: { + skill: "get_products", + input: { + buying_mode: "brief", + brief: "Find video products for a pet food campaign" + } + } }] } }); @@ -80,32 +132,33 @@ console.log(response.message); // Human-readable summary ## Message Structure (A2A-Specific) -### Multi-Part Messages +### Profile Invocation Messages -A2A's key advantage is multi-part messages combining text, data, and files: +A profile invocation contains exactly one authoritative DataPart with `{ skill, input }`. It may also contain advisory TextParts. Text never overrides the structured input: ```javascript -// Text + structured data + file +// SDK-generated display label + authoritative structured invocation const response = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [ { - text: "Create campaign with these assets" + text: "AdCP task: create_media_buy" }, { data: { skill: "create_media_buy", - parameters: { - packages: ["pkg_001"], - total_budget: 100000 + 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" } } - }, - { - url: "https://cdn.example.com/hero-video.mp4", - filename: "hero_video_30s.mp4", - mediaType: "video/mp4" } ] } @@ -114,32 +167,26 @@ const response = await a2a.send({ ### Skill Invocation Methods -#### Natural Language (Flexible) -```javascript -// Agent interprets intent -const task = await a2a.send({ - message: { - role: "ROLE_USER", - parts: [{ - text: "Find premium CTV inventory under $50 CPM" - }] - } -}); -``` +#### 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 exact parameters +// Explicit skill with an exact task request const task = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [{ data: { skill: "get_products", - parameters: { - max_cpm: 50, - channels: ["ctv"], - tier: "premium" + input: { + buying_mode: "brief", + brief: "Premium CTV inventory under $50 CPM" } } }] @@ -147,23 +194,23 @@ const task = await a2a.send({ }); ``` -#### Hybrid Approach (Recommended) +#### Structured Invocation with Generated Display Text ```javascript -// Context + explicit execution for best results +// Display text contains no information beyond the structured invocation const task = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [ { - text: "Looking for inventory for spring campaign targeting millennials" + text: "AdCP task: get_products" }, { data: { skill: "get_products", - parameters: { - audience: "millennials", - season: "Q2_2024", - max_cpm: 45 + input: { + buying_mode: "brief", + brief: "Premium CTV inventory for a spring campaign under $45 CPM" } } } @@ -172,20 +219,26 @@ const task = await a2a.send({ }); ``` +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](/docs/building/by-layer/L0/a2a-profile-extension#invocation-message) for the normative rules. + **Status Handling**: See [Task Lifecycle](/docs/building/by-layer/L3/task-lifecycle) for complete status handling patterns. ## A2A Response Format **New in AdCP 1.6.0**: All responses include unified status field. -### Canonical Response Structure +### 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", // AdCP unified status (see Core Concepts) - "taskId": "task-123", // A2A task identifier + "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", @@ -196,6 +249,8 @@ AdCP responses over A2A **MUST** include at least one DataPart (a Part carrying }, { "data": { + "status": "completed", + "cache_scope": "account", "products": [...], "total": 12 } @@ -209,8 +264,8 @@ The A2A 1.0 wire format carries no `kind` discriminator — the Part's content t **For complete canonical format specification, see [A2A Response Format](/docs/building/by-layer/L0/a2a-response-format).** -### A2A-Specific Fields -- **taskId**: A2A task identifier for streaming updates +### 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](/docs/building/by-layer/L0/a2a-response-extraction#wire-format-compatibility)) @@ -229,7 +284,7 @@ if (artifact) { const isData = (p) => p.data != null || p.kind === 'data'; const message = artifact.parts?.find(isText)?.text; - const data = artifact.parts?.find(isData)?.data; + const data = artifact.parts?.filter(isData).at(-1)?.data; return { artifactId: artifact.artifactId, @@ -244,140 +299,81 @@ return { status: response.status }; **For complete response structure requirements, error handling, and implementation patterns, see [A2A Response Format](/docs/building/by-layer/L0/a2a-response-format).** -## Push Notifications (A2A-Specific) - -A2A defines push notifications natively via `PushNotificationConfig`. When you configure a webhook URL, the server will POST task updates directly to your endpoint instead of requiring you to poll. - -### Correlation: payload field, not URL - -Correlate incoming notifications using `operation_id` (and `task_type`) from the payload body — **never** by parsing `pushNotificationConfig.url`. The URL is opaque to the server; the wire-level source of truth for correlation is the payload field. See [Webhooks — Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract (it applies to both MCP and A2A — every comparable async-notification protocol in ad tech makes the URL opaque to the firing entity). +## Push Notifications and AdCP Webhooks -Buyers MAY encode `operation_id` in the URL path or query as a routing aid for their own HTTP server — many web frameworks dispatch on path segments before parsing the body — but that's a buyer-side server design choice, not part of the wire contract. A buyer's server-routing template is not visible to the seller; the seller reads `operation_id` only from the buyer-supplied `pushNotificationConfig.operation_id` field and echoes it verbatim in the payload. +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. -**URL templates (buyer-side server routing only):** +**Durable AdCP webhook:** ```javascript -// Path parameters -url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}` - -// Query parameters -url: `https://buyer.com/webhooks/a2a?task=${taskType}&op=${operationId}` - -// Or fully opaque — the seller doesn't care about URL shape -url: `https://buyer.com/webhooks/${randomToken}` -``` - -**Example Configuration:** - -```javascript -const operationId = "op_nike_q1_2025"; -const taskType = "create_media_buy"; +const operationId = crypto.randomUUID(); await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [{ data: { skill: "create_media_buy", - parameters: { /* task params */ } + 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 + } + } } }] - }, - pushNotificationConfig: { - url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}`, - operation_id: operationId, // canonical correlation channel — seller echoes verbatim - token: "client-validation-token", // Optional: for client-side validation - authentication: { - schemes: ["bearer"], - credentials: "shared_secret_32_chars" - } } }); ``` -For webhook payload formats, protocol comparison, and detailed handling examples, see [Webhooks](/docs/building/by-layer/L3/webhooks). - -## SSE Streaming (A2A-Specific) - -A2A's key advantage is real-time updates via Server-Sent Events: - -AdCP still owns the application-layer task lifecycle on A2A. A2A `Task`, `taskId`, SSE, and push-notification frames are transport delivery mechanics; the durable business operation remains the AdCP payload keyed by AdCP `task_id`. A completed A2A task can still carry an AdCP response whose payload says `status: 'submitted'`. - -### Task Monitoring +When transport-level delivery is useful while the handler is still running, +pass the separate A2A 1.0 configuration: ```javascript -class A2aTaskMonitor { - constructor(taskId) { - this.taskId = taskId; - this.events = new EventSource(`/a2a/tasks/${taskId}/events`); - - this.events.addEventListener('status', (e) => { - const update = JSON.parse(e.data); - this.handleStatusUpdate(update); - }); - - this.events.addEventListener('progress', (e) => { - const data = JSON.parse(e.data); - console.log(`${data.percentage}% - ${data.message}`); - }); - } - - handleStatusUpdate(update) { - switch (update.status) { - case 'input-required': - // Handle clarification/approval needed - this.emit('input-required', update); - break; - case 'completed': - this.events.close(); - this.emit('completed', update); - break; - case 'failed': - this.events.close(); - this.emit('failed', update); - break; - } +configuration: { + taskPushNotificationConfig: { + id: crypto.randomUUID(), + url: "https://buyer.example/webhooks/a2a", + token: "client-validation-token" } } ``` -### Real-Time Updates Example +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. -```javascript -// Start long-running operation -const response = await a2a.send({ - message: { - role: "ROLE_USER", - parts: [{ - data: { - skill: "create_media_buy", - parameters: { packages: ["pkg_001"], total_budget: 100000 } - } - }] - } -}); +For webhook payload formats, protocol comparison, and detailed handling examples, see [Webhooks](/docs/building/by-layer/L3/webhooks). -// Monitor A2A transport progress in real time via SSE -if (response.status === 'working' || response.status === 'submitted') { - const monitor = new A2aTaskMonitor(response.taskId); - - monitor.on('progress', (data) => { - updateUI(`${data.percentage}%: ${data.message}`); - }); - - monitor.on('completed', (final) => { - // Extract last DataPart from the artifact — don't assume a positional index. - const parts = final.artifacts[0].parts; - const dataParts = parts.filter(p => p.data != null || p.kind === 'data'); - const payload = dataParts[dataParts.length - 1]?.data; - if (payload?.status === 'submitted') { - // A2A delivery completed, but the AdCP operation is still queued. - return pollAdcpTask(payload.task_id); - } - console.log('Created:', payload?.media_buy_id); - }); -} -``` +## 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 @@ -395,6 +391,7 @@ When a task finishes, the server sends the full `Task` object wrapped in the A2A "timestamp": "2026-01-22T10:30:00.000Z" }, "artifacts": [{ + "artifactId": "media-buy-result", "name": "task_result", "parts": [ { @@ -402,7 +399,10 @@ When a task finishes, the server sends the full `Task` object wrapped in the A2A }, { "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": [ { @@ -420,7 +420,7 @@ When a task finishes, the server sends the full `Task` object wrapped in the A2A **CRITICAL**: For **`completed`, `failed`, or `rejected`** status, the AdCP task result **MUST** be in `.artifacts[0].parts[]`. If the server has only a free-text fatal message (no structured payload), it MAY fall back to `status.message.parts[]` — clients handle both. -The A2A 1.0 `StreamResponse` oneof wraps every SSE frame and push-notification payload with exactly one of: `{ task }`, `{ statusUpdate }`, `{ artifactUpdate }`, `{ message }` (A2A 1.0 §3.2.3, §4.3.3). Non-streaming responses from `tasks/get` and v0.3 servers deliver the bare object. Clients unwrap before reading fields. +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** @@ -434,6 +434,9 @@ During execution, interim status updates can include optional data in `status.me "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" }, @@ -450,7 +453,7 @@ During execution, interim status updates can include optional data in `status.me } ``` -**All status payloads use AdCP schemas**: Both final statuses (completed/failed) and interim statuses (working, input-required, submitted) have corresponding AdCP schemas referenced in [`async-response-data.json`](https://adcontextprotocol.org/schemas/v3/core/async-response-data.json). Note that interim status schemas are evolving and may change in future versions, so implementors may choose to handle them more loosely. +**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 @@ -459,7 +462,7 @@ Per the [A2A 1.0 specification](https://a2a-protocol.org/latest/specification/#4 | Envelope Key | Inner Payload | When Used | What It Contains | |--------------|---------------|-----------|------------------| | `task` | `Task` | Final states (`completed`, `failed`, `canceled`, `rejected`) or when full context needed | Complete task object with all history and artifact data | -| `statusUpdate` | `TaskStatusUpdateEvent` | Status transitions during execution (`working`, `input-required`, `auth-required`, `submitted`) | Lightweight status change with message parts | +| `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 | @@ -467,7 +470,7 @@ For AdCP, most webhooks will be: - `{ task }` for final results (`completed`, `failed`, `rejected`) - `{ statusUpdate }` for progress updates (`working`, `input-required`, `auth-required`) -Clients unwrap the single-key envelope before reading fields. Non-streaming responses (e.g., `tasks/get`) deliver the bare payload — unwrapping a single-key envelope is a no-op there. +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. @@ -478,11 +481,13 @@ Clients unwrap the single-key envelope before reading fields. Non-streaming resp 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` +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 @@ -504,7 +509,7 @@ The DataPart `data` field in A2A webhooks uses status-specific schemas: | `working` | `[task]-async-response-working.json` | Progress info (`percentage`, `step`) | | `input-required` | `[task]-async-response-input-required.json` | Requirements, approval data | | `auth-required` (1.0) | `[task]-async-response-auth-required.json` | Auth challenge (scheme, URL, scopes) | -| `submitted` | `[task]-async-response-submitted.json` | Acknowledgment (usually minimal) | +| native A2A `submitted` | Transport status payload | Handler has not yet returned an AdCP response | Schema reference: [`async-response-data.json`](https://adcontextprotocol.org/schemas/v3/core/async-response-data.json) @@ -556,7 +561,7 @@ app.post('/webhooks/a2a/:taskType/:operationId', async (req, res) => { if (FINAL.includes(normalizedStatus)) { // FINAL STATES: Extract from .artifacts (fallback to status.message.parts) const artifactParts = webhook.artifacts?.[0]?.parts; - const dataPart = artifactParts?.find(isDataPart) + const dataPart = artifactParts?.filter(isDataPart).at(-1) ?? webhook.status?.message?.parts?.find(isDataPart); const textPart = artifactParts?.find(isTextPart) ?? webhook.status?.message?.parts?.find(isTextPart); @@ -646,105 +651,83 @@ app.post('/webhooks/a2a/:taskType/:operationId', async (req, res) => { ## Context Management (A2A-Specific) -**Key Advantage**: A2A handles context automatically - no manual context_id management needed. - -### Automatic Context +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 - A2A creates context automatically +// First request: the server assigns a contextId. const response1 = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", - parts: [{ text: "Find premium video products" }] + parts: [{ data: { + skill: "get_products", + input: { buying_mode: "brief", brief: "Find premium video products" } + }}] } }); -// Follow-up - A2A remembers context automatically +// 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: [{ text: "Filter for sports content" }] + parts: [{ data: { + skill: "get_products", + input: { buying_mode: "brief", brief: "Premium sports video products" } + }}] } }); -// System automatically connects this to previous request ``` -### Explicit Context (Optional) +## File and Multi-Modal Inputs -```javascript -// When you need explicit control -const response2 = await a2a.send({ - contextId: response1.contextId, // Optional - A2A tracks this anyway - message: { - role: "ROLE_USER", - parts: [{ text: "Refine those results" }] - } -}); -``` - -**vs. MCP**: Unlike MCP's manual context_id management, A2A handles session continuity at the protocol level. - -## Multi-Modal Messages (A2A-Specific) - -A2A's unique capability - combine text, data, and files in one message: +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 -// Upload creative with campaign context in single message +// The asset URL is part of the typed AdCP request. const response = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [ { - text: "Add this hero video to the premium sports campaign" + text: "AdCP task: sync_creatives" }, { data: { skill: "sync_creatives", - parameters: { - media_buy_id: "mb_12345", - action: "upload_and_assign" + 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 + } + } + }] } } - }, - { - url: "https://cdn.example.com/hero-30s.mp4", - filename: "sports_hero_30s.mp4", - mediaType: "video/mp4" } ] } }); ``` -### Campaign Brief + Assets - -```javascript -// Submit comprehensive campaign brief -await a2a.send({ - message: { - role: "ROLE_USER", - parts: [ - { - text: "Campaign brief and assets for Q1 launch" - }, - { - url: "https://docs.google.com/campaign-brief.pdf", - filename: "Q1_campaign_brief.pdf", - mediaType: "application/pdf" - }, - { - data: { - budget: 250000, - kpis: ["reach", "awareness", "conversions"], - target_launch: "2026-01-15" - } - } - ] - } -}); -``` +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 @@ -757,12 +740,13 @@ All AdCP tasks are available as A2A skills. Use explicit invocation for determin // Standard pattern for explicit skill invocation await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [{ data: { - skill: "skill_name", // Exact name from Agent Card - parameters: { // Task-specific parameters - // See task documentation for parameters + skill: "skill_id", // Exact AgentSkill.id from Agent Card + input: { // Task-specific request + // See task documentation for request fields } } }] @@ -779,7 +763,7 @@ await a2a.send({ ## Agent Cards -A2A agents advertise capabilities via Agent Cards at `.well-known/agent.json`. +A2A 1.0 agents advertise capabilities via Agent Cards at `.well-known/agent-card.json`. ### Discovering Agent Cards ```javascript @@ -787,11 +771,11 @@ A2A agents advertise capabilities via Agent Cards at `.well-known/agent.json`. const agentCard = await a2a.getAgentCard(); // List available skills -const skillNames = agentCard.skills.map(skill => skill.name); -console.log('Available skills:', skillNames); +const skillIds = agentCard.skills.map(skill => skill.id); +console.log('Available skill IDs:', skillIds); // Get skill details -const getProductsSkill = agentCard.skills.find(s => s.name === 'get_products'); +const getProductsSkill = agentCard.skills.find(s => s.id === 'get_products'); console.log('Examples:', getProductsSkill.examples); // Pick a transport interface (1.0) @@ -812,11 +796,15 @@ In 1.0, the top-level `url` and `protocolVersion` fields from v0.3 are replaced "version": "1.0.0", "securitySchemes": { "bearerAuth": { - "type": "http", - "scheme": "bearer" + "httpAuthSecurityScheme": { + "scheme": "Bearer", + "bearerFormat": "JWT" + } } }, - "security": [{"bearerAuth": []}], + "securityRequirements": [{ + "schemes": { "bearerAuth": { "list": [] } } + }], "supportedInterfaces": [ { "url": "https://sales.example.com/a2a/jsonrpc", @@ -824,34 +812,37 @@ In 1.0, the top-level `url` and `protocolVersion` fields from v0.3 are replaced "protocolVersion": "1.0" } ], - "defaultInputModes": ["text/plain", "application/json"], + "defaultInputModes": ["application/json"], "defaultOutputModes": ["application/json"], "capabilities": { "streaming": true, "pushNotifications": true, - "extendedAgentCard": false + "extendedAgentCard": false, + "extensions": [ + { + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "description": "AdCP structured task invocation profile", + "required": true + } + ] }, "skills": [ { - "name": "get_products", + "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" ] } - ], - "extensions": [ - { - "uri": "https://adcontextprotocol.org/extensions/adcp", - "description": "AdCP media buying protocol support", - "required": false, - "params": { - "adcp_version": "2.6.0", - "protocols_supported": ["media_buy"], - "extensions_supported": ["sustainability"] - } - } ] } ``` @@ -882,64 +873,67 @@ Python SDK servers must also pass `enable_v0_3_compat=True` when constructing ro ### AdCP Extension -**Recommended**: Use [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for runtime capability discovery. The agent card extension provides static metadata for agent registries and discovery services. +Use [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) for runtime capability discovery. The Agent Card extension declaration identifies the A2A wire profile only. -Include the AdCP extension in your agent card's `extensions` array to declare AdCP support programmatically. +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 uses an `extensions` array where each extension has: -- **`uri`**: Extension identifier (use `https://adcontextprotocol.org/extensions/adcp`) +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`**: Whether clients must support this extension (typically `false` for AdCP) -- **`params`**: AdCP-specific configuration (see schema below) +- **`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.json') +const agentCard = await fetch('https://sales.example.com/.well-known/agent-card.json') .then(r => r.json()); -// Find the AdCP extension in the extensions array -const adcpExt = agentCard.extensions?.find( - ext => ext.uri === 'https://adcontextprotocol.org/extensions/adcp' +// Find the AdCP profile under AgentCapabilities. +const adcpExt = agentCard.capabilities.extensions?.find( + ext => ext.uri === 'https://adcontextprotocol.org/extensions/adcp/v3' ); if (adcpExt) { - console.log('AdCP Version:', adcpExt.params.adcp_version); - console.log('Supported domains:', adcpExt.params.protocols_supported); - // ["media_buy", "creative", "signals"] - console.log('Typed extensions:', adcpExt.params.extensions_supported); - // ["sustainability"] + // 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); } ``` -**Extension Params**: The `adcp-extension.json` schema was used in v2 to describe these params, but was removed in v3. For v3+ agents, use the `get_adcp_capabilities` task for runtime capability discovery instead. The extension `params` object above shows the typical structure. +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`](/docs/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](/docs/reference/versioning#version-negotiation) for the full contract. ::: **Benefits**: -- Clients can discover AdCP capabilities without making test calls -- Declare which protocol domains you implement (media_buy, creative, signals) -- Enable compatibility checks based on version +- 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 -// Initialize A2A client -const a2a = new A2AClient({ /* config */ }); - // Use unified status handling (see Core Concepts) -async function handleA2aResponse(response) { +async function handleA2aResponse(response, invocation) { switch (response.status) { case 'input-required': - // Handle clarification (see Core Concepts for patterns) - const input = await promptUser(response.message); + // Collect fields and rebuild a complete, schema-valid typed request. + const refinedInput = await collectTypedInput(invocation.input, response.message); return a2a.send({ - contextId: response.contextId, message: { + messageId: crypto.randomUUID(), + taskId: response.taskId, + contextId: response.contextId, role: "ROLE_USER", - parts: [{ text: input }] + parts: [{ data: { skill: invocation.skill, input: refinedInput } }] } }); @@ -961,13 +955,14 @@ async function handleA2aResponse(response) { // Example usage with multi-modal message const result = await a2a.send({ message: { + messageId: crypto.randomUUID(), role: "ROLE_USER", parts: [ - { text: "Find luxury car inventory" }, + { text: "AdCP task: get_products" }, { data: { skill: "get_products", - parameters: { + input: { idempotency_key: "550e8400-e29b-41d4-a716-446655442071", buying_mode: "brief", brief: "Luxury car inventory for in-market shoppers" @@ -978,7 +973,14 @@ const result = await a2a.send({ } }); -const finalResult = await handleA2aResponse(result); +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 diff --git a/docs/building/by-layer/L0/a2a-profile-extension.mdx b/docs/building/by-layer/L0/a2a-profile-extension.mdx new file mode 100644 index 0000000000..898b35826d --- /dev/null +++ b/docs/building/by-layer/L0/a2a-profile-extension.mdx @@ -0,0 +1,298 @@ +--- +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/v3/`. + +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/v3/`—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. + +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. + +## 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](/docs/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. +- 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](/docs/reference/test-vectors) for versioning guidance. + +## References + +- [A2A 1.0 specification](https://a2a-protocol.org/latest/specification/) +- [A2A extensions](https://a2a-protocol.org/latest/topics/extensions/) +- [A2A Guide](/docs/building/by-layer/L0/a2a-guide) +- [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) +- [Calling an AdCP agent](/docs/protocol/calling-an-agent) diff --git a/docs/building/by-layer/L0/a2a-response-extraction.mdx b/docs/building/by-layer/L0/a2a-response-extraction.mdx index a915ec5a62..27d0f1d551 100644 --- a/docs/building/by-layer/L0/a2a-response-extraction.mdx +++ b/docs/building/by-layer/L0/a2a-response-extraction.mdx @@ -15,6 +15,7 @@ The rules on this page layer AdCP-specific semantics onto A2A. Non-AdCP A2A agen - **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](/docs/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 @@ -24,7 +25,13 @@ This algorithm handles both **A2A 1.0** and **v0.3** responses. Extraction must **Part shape.** A 1.0 DataPart has a non-null `data` field and no `kind`. A v0.3 DataPart has `kind: "data"` and a `data` field. Both satisfy "the `data` field is a non-null object." The same holds for TextParts (`text` field present) and FileParts (`url`/`raw` in 1.0, or `kind: "file"` in v0.3). Per A2A 1.0 §4.1.6, a Part is a strict `oneof` — exactly one of `text`, `raw`, `url`, or `data` is set. Clients receiving a Part with multiple content fields SHOULD treat it as malformed. -**Streaming envelope.** A2A 1.0 wraps streaming responses and push-notification payloads in a `StreamResponse` oneof with exactly one of the keys `task`, `message`, `statusUpdate`, or `artifactUpdate` (A2A 1.0 §3.2.3, §4.3.3). Non-streaming responses (e.g., `tasks/get`, or v0.3 over HTTP) deliver the bare object. Extraction unwraps a single-key envelope before applying the algorithm below. +**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 @@ -37,7 +44,7 @@ The extraction location depends on the task's status. State names in this table | `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 | -| `submitted` | 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) | @@ -45,17 +52,17 @@ Final states fall back to `status.message.parts[]` when `.artifacts` is absent o 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. Extractors MUST return the DataPart unchanged and MUST NOT overwrite its `status` with the A2A transport state. +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 stream envelopes.** If the input is an object with exactly one top-level key named `task`, `message`, `statusUpdate`, or `artifactUpdate` and that key's value is a non-null, non-array object, replace the input with that value (A2A 1.0 `StreamResponse` oneof). Bare `Task` / `TaskStatusUpdateEvent` objects — non-streaming responses or v0.3 — pass through unchanged. An `artifactUpdate` carries no task status; once unwrapped its `status.state` is absent and step 1 returns null. +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 (out-of-band agent messages) MUST be ignored by task-oriented extractors — step 1 returns null when the unwrapped object has no `status.state`. Webhook/SSE handlers MUST NOT return a `200 OK` acknowledgment for unrecognized `{ message }` envelopes; return `400 Bad Request` or silently discard at the transport layer to avoid acting as a presence oracle for attackers probing endpoints. + 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). diff --git a/docs/building/by-layer/L0/a2a-response-format.mdx b/docs/building/by-layer/L0/a2a-response-format.mdx index c309cdb4a3..784e0430f2 100644 --- a/docs/building/by-layer/L0/a2a-response-format.mdx +++ b/docs/building/by-layer/L0/a2a-response-format.mdx @@ -13,6 +13,8 @@ Examples below use **A2A 1.0** wire format: Parts carry no `kind` discriminator 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](/docs/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 @@ -25,27 +27,34 @@ For v0.3 servers, the same DataPart becomes `{ "kind": "data", "data": {...} }` - Use the last DataPart as authoritative when multiple data parts exist - NOT wrap AdCP payloads in custom framework objects (no `{ response: {...} }` wrappers) -**Recommended pattern:** +**Recommended non-streaming `SendMessageResponse` pattern:** ```json { - "status": "completed", - "taskId": "task_123", - "contextId": "ctx_456", - "artifacts": [{ - "name": "task_result", - "parts": [ - { - "text": "Found 12 video products perfect for pet food campaigns" - }, - { - "data": { - "products": [...], - "total": 12 + "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 + } } - } - ] - }] + ] + }] + } } ``` @@ -53,9 +62,37 @@ For v0.3 servers, the same DataPart becomes `{ "kind": "data", "data": {...} }` - **DataPart** (Part with `data` field): Structured AdCP response payload — **required** - **FilePart** (Part with `url` or `raw` field): Optional file references (previews, reports) -**Multiple artifacts:** Only for fundamentally distinct deliverables (e.g., creative asset + separate trafficking report). Rare in AdCP - prefer single artifact with multiple parts. +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) -### Interim Responses (working, submitted, input-required, auth-required) +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. @@ -67,6 +104,9 @@ Interim status updates are delivered as `TaskStatusUpdateEvent`, with optional p "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": [ { @@ -84,7 +124,7 @@ Interim status updates are delivered as `TaskStatusUpdateEvent`, with optional p } ``` -When delivered over SSE or as a push notification, this event is wrapped in the A2A 1.0 `StreamResponse` oneof: `{ "statusUpdate": { … } }`. Non-streaming responses (e.g. `tasks/get`) deliver the bare object. Clients unwrap before reading `status.state` — see [A2A Response Extraction](/docs/building/by-layer/L0/a2a-response-extraction#extraction-algorithm). +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](/docs/building/by-layer/L0/a2a-response-extraction#extraction-algorithm). **Interim response characteristics:** - **TextPart** is recommended for human-readable status @@ -111,7 +151,9 @@ When delivered over SSE or as a push notification, this event is wrapped in the // ✅ CORRECT - Direct AdCP payload { "data": { - "products": [...] // ← Direct schema-compliant response + "status": "completed", + "cache_scope": "account", + "products": [] } } ``` @@ -133,7 +175,7 @@ This section defines EXACTLY how clients MUST extract AdCP responses from A2A pr | Status | Webhook Type | Data Location | Schema Required? | Returns | |--------|--------------|---------------|-----------------|---------| | `working` | `TaskStatusUpdateEvent` | `status.message.parts[]` | ✅ Yes (if present) | `{ status, taskId, message, data? }` | -| `submitted` | `TaskStatusUpdateEvent` | `status.message.parts[]` | ✅ Yes (if present) | `{ status, taskId, message, data? }` | +| 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 }` | @@ -291,8 +333,11 @@ Putting it all together with proper handling of both Task and TaskStatusUpdateEv ```javascript async function executeTask(taskName, params) { const response = await a2aClient.send({ - task: taskName, - params: params + message: { + messageId: crypto.randomUUID(), + role: 'ROLE_USER', + parts: [{ data: { skill: taskName, input: params } }] + } }); // 1. Status-based handling (extracts from correct location) @@ -332,30 +377,24 @@ if (result.status === 'working') {
Why this pattern? -During streaming operations, intermediate responses may include old progress data: +An artifact can accumulate multiple DataParts while streaming. Only the final +Task artifact uses last-DataPart authority: ```json -// Working status with progress { - "status": "working", - "artifacts": [{ - "parts": [ - {"text": "Searching inventory..."}, - {"data": {"progress": 25}} - ] - }] -} - -// Completed - last data part is authoritative -{ - "status": "completed", - "artifacts": [{ - "parts": [ - {"text": "Found 12 products"}, - {"data": {"progress": 25}}, // Old - {"data": {"products": [...], "total": 12}} // ← Authoritative - ] - }] + "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}} + ] + }] + } } ``` @@ -374,6 +413,9 @@ const workingResponse = { status: { state: 'TASK_STATE_WORKING', message: { + messageId: 'msg-progress-001', + taskId: 'task_123', + contextId: 'ctx_456', role: 'ROLE_AGENT', parts: [ { text: 'Processing inventory...' }, @@ -389,16 +431,17 @@ assert(result1.message === 'Processing inventory...', 'Should extract text from // Test 2: Completed status (Task) - extract from .artifacts const completedResponse = { - taskId: 'task_123', + 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: { products: [...], total: 3 } } + { data: { status: 'completed', cache_scope: 'account', products: [...], total: 3 } } ] }] }; @@ -409,9 +452,10 @@ assert(Array.isArray(result2.data.products), 'Data should be direct AdCP payload // Test 3: Wrapper detection (should reject) const wrappedResponse = { - taskId: 'task_123', + id: 'task_123', status: { state: 'TASK_STATE_COMPLETED' }, artifacts: [{ + artifactId: 'product-result', parts: [ { data: { response: { products: [...] } } } ] @@ -455,331 +499,148 @@ function badClientUsage(result) { } ``` -## Error Handling -### Task-Level Errors (Partial Failures) +## Error Handling -Task executed but couldn't complete fully. Use `errors` array in DataPart with `status: "completed"`: +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 { - "status": "completed", - "taskId": "task_123", - "artifacts": [{ - "parts": [ - { - "text": "Signal discovery completed with partial results" - }, - { + "task": { + "id": "task_123", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "signal-result", + "parts": [{ "data": { - "signals": [...], + "status": "completed", + "signals": [], "errors": [{ "code": "NO_DATA_IN_REGION", - "message": "No signal data available for Australia", - "field": "countries[1]", - "details": { - "requested_country": "AU", - "available_countries": ["US", "CA", "GB"] - } + "message": "No signal data available for Australia" }] } - } - ] - }] + }] + }] + } } ``` -**When to use errors array:** -- Platform authorization issues (`PLATFORM_UNAUTHORIZED`) -- Partial data availability -- Validation issues in subset of data - -### Protocol-Level Errors (Fatal) - -Task couldn't execute. Use `status: "failed"` with message: +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 { - "taskId": "task_456", - "status": "failed", - "message": { - "role": "ROLE_AGENT", - "parts": [{ - "text": "Authentication failed: Invalid or expired API token" + "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" + } + } + }] }] } } ``` -**When to use status: failed:** -- Authentication failures (invalid credentials, expired tokens) -- Invalid request parameters (malformed JSON, missing required fields) -- Resource not found (unknown taskId, expired context) -- System errors (database unavailable, internal service failure) - -### Where the Error Lives: Decision Rule - -Placement is chosen by what the server has and which state it's in: - -| Situation | State | Location | Payload | -|---|---|---|---| -| Task executed, subset failed | `completed` | `artifacts[0].parts[]` DataPart | `{ , errors: [...] }` | -| Task failed with structured error | `failed` | `artifacts[0].parts[]` DataPart | `{ adcp_error: {...} }` | -| Task rejected by policy/validation (1.0) | `rejected` | `artifacts[0].parts[]` DataPart | `{ adcp_error: {...} }` | -| System-initiated cancel (timeout, upstream failure) | `canceled` | `artifacts[0].parts[]` DataPart | `{ adcp_error: {...} }` | -| User-initiated cancel (`tasks/cancel`) | `canceled` | `status.message.parts[]` TextPart | Human-readable text only | -| Protocol/transport failure, no artifact produced | `failed` | `status.message.parts[]` TextPart | Human-readable text only | - -**Rule of thumb:** if the server has structured error data, put it in artifacts as a DataPart. `status.message` is the free-text fallback for cases where no task artifact was ever produced (JSON-RPC parse errors, auth handshake failures, malformed requests, or a user-initiated cancel with no further detail). A2A 1.0 §3.7 reinforces this: *"Messages SHOULD NOT be used to deliver task outputs. Results SHOULD be returned using Artifacts."* - -**Native A2A `rejected` vs `failed`.** Use native A2A `rejected` when the server refuses to execute the invocation (for example, an authentication, tier, or transport-level policy check before task work starts). Use `failed` when execution started and encountered a fatal error. Both carry `adcp_error` in the artifact. A task-specific AdCP business rejection is different: A2A is `completed`, the DataPart carries `status: "rejected"`, and there is no `adcp_error`. - -**Cancel origin is client-reconciled, not seller-attributed.** `status.state: "canceled"` (or `TASK_STATE_CANCELED`) does not tell the caller whether the cancel was user-initiated or system-initiated — a seller could place `adcp_error` in artifacts for what was actually a user-initiated cancel to mislead the buyer's bookkeeping or retry logic. Clients MUST reconcile cancel origin locally: if the caller has an outstanding `tasks/cancel` request for this `taskId`, treat the cancel as user-initiated regardless of payload and ignore any `adcp_error` the seller attached. Clients MUST NOT retry a user-initiated cancel on the basis of a seller-sent `adcp_error.recovery` hint. - -## Status Mapping - -AdCP uses A2A's TaskState enum directly: - -| A2A Status | Payload Type | Data Location | AdCP Usage | -|------------|--------------|---------------|------------| -| `completed` | `Task` | `.artifacts` | Task finished successfully, data in DataPart, optional errors array | -| `failed` | `Task` | `.artifacts` (or `status.message` for text-only) | Fatal error preventing completion, `adcp_error` when structured | -| `rejected` (1.0) | `Task` | `.artifacts` | Native A2A invocation rejection, `adcp_error` with rejection reason | -| `canceled` | `Task` | `.artifacts` (typically none) | Task canceled by user or system | -| `input-required` | `TaskStatusUpdateEvent` | `status.message.parts` | Need user input/approval, data + text explaining what's needed | -| `auth-required` (1.0) | `TaskStatusUpdateEvent` | `status.message.parts` | Authentication challenge during task execution (scheme, URL, scopes) | -| `working` | `TaskStatusUpdateEvent` | `status.message.parts` | Processing (< 120s), optional progress data | -| `submitted` | `TaskStatusUpdateEvent` | `status.message.parts` | Long-running (hours/days), minimal data, use webhooks/polling | +| 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 -Async operations (`status: "submitted"`) deliver the same artifact structure in webhooks: - -```json -POST /webhook-endpoint -{ - "taskId": "task_123", - "status": "completed", - "timestamp": "2026-01-22T10:30:00.000Z", - "artifacts": [{ - "parts": [ - {"text": "Media buy approved and live"}, - {"data": { - "media_buy_id": "mb_456", - "packages": [...], - "creative_deadline": "2026-01-30T23:59:59.000Z" - }} - ] - }] -} -``` - -Extract AdCP data using the same last-DataPart pattern. **For webhook authentication, retry patterns, and security**, see [Webhooks](/docs/building/by-layer/L3/webhooks). - -## File Parts in Responses - -Creative operations MAY include file references: +A2A push notifications use a `StreamResponse` branch. Terminal delivery wraps +the same Task structure used by `SendMessageResponse`: ```json { - "status": "completed", - "artifacts": [{ - "parts": [ - {"text": "Creative uploaded and preview generated"}, - {"data": { - "creative_id": "cr_789", - "format_kind": "video_hosted", - "status": "ready" - }}, - {"url": "https://cdn.example.com/cr_789/preview.mp4", "filename": "preview.mp4", "mediaType": "video/mp4"} - ] - }] -} -``` - -**File part usage:** Preview URLs, generated assets, trafficking reports. **Not for** raw AdCP response data (always use DataPart). - -## Retry and Idempotency - -### TaskId-Based Deduplication - -A2A's `taskId` enables retry detection. Agents SHOULD: -- Return cached response if `taskId` matches a completed operation (within TTL window) -- Reject duplicate `taskId` submission if operation is still in progress - -```json -// Duplicate taskId during active operation -{ - "taskId": "task_123", - "status": "failed", - "message": { - "role": "ROLE_AGENT", - "parts": [{ - "text": "Task 'task_123' is already in progress. Use tasks/get to check status." + "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": [] } } + ] }] } } ``` -## Examples - -
-Product Discovery Success +Interim delivery selects `statusUpdate`, and its server-authored Message carries +its own `messageId` plus the matching `taskId` and `contextId`: ```json { - "status": "completed", - "taskId": "task_001", - "contextId": "ctx_abc", - "artifacts": [{ - "name": "product_catalog", - "parts": [ - { - "text": "Found 8 CTV products targeting sports fans under $50 CPM" - }, - { - "data": { - "products": [ - { - "product_id": "ctv_sports_premium", - "name": "Premium Sports CTV" - } - // ... 7 more products - ] - } + "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" } } + ] } - ] - }] -} -``` -
- -
-Media Buy with Approval Required - -```json -{ - "status": "input-required", - "taskId": "task_002", - "contextId": "ctx_def", - "artifacts": [{ - "name": "approval_request", - "parts": [ - { - "text": "Media buy exceeds auto-approval limit ($100K). Please approve to proceed." - }, - { - "data": { - "media_buy_id": "mb_pending_456", - "packages": [ - { "package_id": "pkg_pending_001" }, - { "package_id": "pkg_pending_002" } - ], - "total_budget": 150000, - "currency": "USD", - "creative_deadline": "2026-02-01T23:59:59.000Z" - } - } - ] - }] -} -``` -
- -
-Signal Discovery with Partial Failure - -```json -{ - "status": "completed", - "taskId": "task_003", - "contextId": "ctx_ghi", - "artifacts": [{ - "name": "signal_results", - "parts": [ - { - "text": "Found 3 signals for luxury automotive. Note: No data available for Australia region." - }, - { - "data": { - "signals": [ - { - "signal_id": "lux_auto_us", - "name": "Luxury Auto Intenders - US", - "reach": 2500000 - } - ], - "total": 3, - "errors": [{ - "code": "NO_DATA_IN_REGION", - "message": "No signal data available for requested region: Australia", - "field": "countries[1]", - "details": { - "requested_country": "AU", - "available_countries": ["US", "CA", "GB"] - } - }] - } - } - ] - }] -} -``` -
- -
-Platform Authorization Issue (Task-Level Error) - -Platform/operation-specific authorization failures are task-level errors: - -```json -{ - "status": "completed", - "taskId": "task_004", - "contextId": "ctx_jkl", - "artifacts": [{ - "name": "signal_activation_result", - "parts": [ - { - "text": "Signal activation failed: Account not authorized for Peer39 data on PubMatic" - }, - { - "data": { - "errors": [{ - "code": "PLATFORM_UNAUTHORIZED", - "message": "Account 'brand-456-pm' not authorized for Peer39 data on PubMatic. Contact your PubMatic account manager to enable access.", - "details": { - "platform": "pubmatic", - "account_id": "brand-456-pm", - "data_provider": "peer39" - } - }] - } - } - ] - }] + } + } } ``` -
-
-Protocol-Level Failure (Fatal) +## File Parts in Responses -Authentication failures are protocol-level errors: +File references may accompany the authoritative DataPart inside the Task +artifact. They never replace the typed AdCP response: ```json { - "taskId": "task_005", - "status": "failed", - "message": { - "role": "ROLE_AGENT", - "parts": [{ - "text": "Authentication failed: Invalid or expired API token. Please refresh your credentials and retry." + "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 @@ -793,8 +654,9 @@ When implementing A2A responses for AdCP: - [ ] **Use last DataPart as authoritative** if multiple exist - [ ] **Never nest AdCP data in custom wrappers** (no `{ response: {...} }` objects) - [ ] **DataPart content MUST match AdCP schemas** (validate against `[task]-response.json`) +- [ ] **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 Responses (status: "working", "submitted", "input-required") - Use `TaskStatusUpdateEvent`:** +**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`) diff --git a/docs/building/by-layer/L3/async-operations.mdx b/docs/building/by-layer/L3/async-operations.mdx index 895fd6cc98..4c76e59260 100644 --- a/docs/building/by-layer/L3/async-operations.mdx +++ b/docs/building/by-layer/L3/async-operations.mdx @@ -22,9 +22,9 @@ Any AdCP task can return one of these statuses. The server chooses based on what **`submitted` is async.** The operation is blocked on something outside the server's control — publisher approval, human review, third-party processing. The caller can always poll the AdCP task status surface with `task_id`. A configured webhook is an additional notification channel for background workflows, not a replacement for polling. :::tip Webhooks for `submitted` operations -**Webhooks** are recommended for background `submitted` operations — they work with any transport (MCP, A2A, REST) and handle operations that outlive a single session. For MCP/REST, the webhook channel is requested with `push_notification_config` on the task request. For A2A, it remains transport configuration at `configuration.pushNotificationConfig`, not a snake_case skill parameter. When a request includes a webhook channel and the server accepts the task by returning `submitted`, the server MUST deliver at least the terminal completion or failure notification to that channel. Intermediate progress notifications are optional unless another operation-specific contract requires them. If the server cannot honor the requested webhook channel, it MUST reject the request with a structured error instead of silently downgrading delivery. See [Push Notifications](/docs/building/by-layer/L3/webhooks). +**AdCP webhooks** are recommended for background `submitted` operations — they work with MCP, A2A, and REST and can outlive a transport session. The registration is always `push_notification_config` in the typed AdCP task request, including inside an A2A profile invocation's `data.input`. A2A's separate `configuration.taskPushNotificationConfig` follows only the A2A transport Task and does not replace the durable AdCP webhook. When a request includes an AdCP webhook channel and the server accepts the task by returning `submitted`, the server MUST deliver at least the terminal completion or failure notification to that channel. Intermediate progress notifications are optional unless another operation-specific contract requires them. If the server cannot honor the requested webhook channel, it MUST reject the request with a structured error instead of silently downgrading delivery. See [Push Notifications](/docs/building/by-layer/L3/webhooks). -**Polling** via the AdCP task polling surface is always valid for `submitted` tasks. In 3.x, that surface is legacy `tasks/get`, with optional `get_task_status` when the seller advertises the alias. Both names accept the same payload shape; multi-account callers SHOULD include `account` so sellers can scope task visibility to the authenticated account + principal pair. See the [polling pattern](#polling-for-submitted-operations) below. +**Polling** via the AdCP task polling surface is always valid for `submitted` tasks. The AdCP v3 A2A profile requires `get_task_status`; MCP sellers may expose that name or the legacy AdCP `tasks/get` alias. Both accept the same payload shape; multi-account callers SHOULD include `account` so sellers can scope task visibility to the authenticated account + principal pair. See the [polling pattern](#polling-for-submitted-operations) below. **Transport-native tasks are not the AdCP lifecycle.** MCP Tasks or A2A task updates may carry or stream an AdCP response, but the durable task state is the AdCP payload: `task_id`, `status`, webhook payloads, and AdCP polling/reconciliation. See [MCP Guide](/docs/building/by-layer/L0/mcp-guide#mcp-tasks-as-a-transport-wrapper). ::: diff --git a/docs/building/by-layer/L3/task-lifecycle.mdx b/docs/building/by-layer/L3/task-lifecycle.mdx index 76ad33f75a..26f7219024 100644 --- a/docs/building/by-layer/L3/task-lifecycle.mdx +++ b/docs/building/by-layer/L3/task-lifecycle.mdx @@ -9,7 +9,7 @@ Every AdCP response includes a `status` field that tells you exactly what state :::note Application-layer task state The status values and lifecycle described here are transport-independent AdCP application state. MCP and A2A task mechanisms may wrap, stream, or deliver an AdCP response, but they do not replace AdCP's `task_id`, webhook payloads, or polling/reconciliation surfaces. -For `submitted` operations, observe the AdCP task with [push notifications](/docs/building/by-layer/L3/webhooks) or the AdCP polling surface. In 3.x, that polling surface is legacy `tasks/get`, with optional `get_task_status` when the seller advertises the alias. Transport-native MCP/A2A `tasks/*` methods use their own wire shapes and are separate from AdCP task polling. +For `submitted` operations, observe the AdCP task with [push notifications](/docs/building/by-layer/L3/webhooks) or the AdCP polling surface. The [AdCP v3 A2A profile](/docs/building/by-layer/L0/a2a-profile-extension) standardizes fresh `get_task_status` invocations; MCP sellers may expose either `get_task_status` or the legacy AdCP `tasks/get` alias. Transport-native MCP/A2A task methods use their own wire shapes and are separate from AdCP task polling. ::: ## Status Values diff --git a/docs/building/by-layer/L3/webhooks.mdx b/docs/building/by-layer/L3/webhooks.mdx index 225ec34d4d..3fdbf70a2c 100644 --- a/docs/building/by-layer/L3/webhooks.mdx +++ b/docs/building/by-layer/L3/webhooks.mdx @@ -57,11 +57,14 @@ This trips people up. There are two naming conventions in play: | Context | Field name | Example | |---------|-----------|---------| | **MCP task arguments** (AdCP JSON) | `push_notification_config` | `{ push_notification_config: { url: ..., operation_id: ... } }` | -| **A2A configuration object** | `pushNotificationConfig` | `configuration: { pushNotificationConfig: { url: ..., operation_id: ... } }` | +| **A2A transport configuration** | `taskPushNotificationConfig` | `configuration: { taskPushNotificationConfig: { id: ..., url: ... } }` | The AdCP field name is always **`push_notification_config`** (snake_case). It goes in the task request body alongside your other task parameters. -For A2A, the A2A protocol wraps it in a `configuration` envelope using camelCase — but the object's contents are identical. +The two objects are not aliases. `push_notification_config` registers an AdCP +application webhook and includes `operation_id`. A2A +`taskPushNotificationConfig` registers delivery for the A2A transport Task and +does not define `operation_id`. ## Adding push_notification_config to a request @@ -86,30 +89,43 @@ Include `push_notification_config` as a task argument, merged with the rest of y ### A2A -For A2A, skill parameters stay in `message.parts[].data.parameters`. The push notification config goes in the top-level `configuration` object: +For a durable AdCP operation invoked through the v3 A2A profile, keep the AdCP +webhook registration inside the typed task input: ```json { "message": { + "messageId": "msg-create-001", + "role": "ROLE_USER", "parts": [{ - "kind": "data", "data": { "skill": "create_media_buy", - "parameters": { - "packages": [...] + "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", + "push_notification_config": { + "url": "https://you.example/webhooks/adcp/create-media-buy", + "operation_id": "op_abc123" + } } } }] - }, - "configuration": { - "pushNotificationConfig": { - "url": "https://you.com/webhooks/adcp/create_media_buy/route_abc123", - "operation_id": "op_abc123" - } } } ``` +If the buyer also wants updates while the A2A handler itself is running, it +may separately set `configuration.taskPushNotificationConfig` with the A2A +fields `id`, `url`, optional `token`, and optional `authentication`. On an +initial `SendMessage`, omit `taskId`; the server has not assigned it yet. That +transport registration ends with the A2A Task and does not replace AdCP +polling or the application webhook above. + ## Operation IDs and URL templates Operation IDs let you correlate incoming webhooks to the right task invocation. The pattern: @@ -262,27 +278,28 @@ A2A sends a `Task` object (for final states) or `TaskStatusUpdateEvent` (for pro ```json { - "id": "task_456", - "contextId": "ctx_123", - "status": { - "state": "completed", - "timestamp": "2025-01-22T10:30:00Z" - }, - "artifacts": [{ - "artifactId": "result", - "parts": [ - { "kind": "text", "text": "Media buy created successfully" }, - { - "kind": "data", - "data": { - "media_buy_id": "mb_12345", - "packages": [ - { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } } - ] + "task": { + "id": "task_456", + "contextId": "ctx_123", + "status": { + "state": "TASK_STATE_COMPLETED", + "timestamp": "2025-01-22T10:30:00.000Z" + }, + "artifacts": [{ + "artifactId": "result", + "parts": [ + { "text": "Media buy created successfully" }, + { + "data": { + "media_buy_id": "mb_12345", + "packages": [ + { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } } + ] + } } - } - ] - }] + ] + }] + } } ``` @@ -290,7 +307,7 @@ A2A sends a `Task` object (for final states) or `TaskStatusUpdateEvent` (for pro | | MCP | A2A | |---|---|---| -| **Config field** | `push_notification_config` (in task args) | `configuration.pushNotificationConfig` (separate from skill params) | +| **Config field** | `push_notification_config` (in task args) | `configuration.taskPushNotificationConfig` (separate from skill params) | | **Envelope** | `mcp-webhook-payload.json` | Native `Task` / `TaskStatusUpdateEvent` | | **Result location** | `result` field | `.artifacts[0].parts[].data` (final) / `status.message.parts[].data` (interim) | | **Data schemas** | Identical AdCP schemas | Identical AdCP schemas | @@ -302,7 +319,7 @@ Webhook envelope shape is determined by **which registration mechanism the buyer | Registered via | Delivered envelope | |---|---| | AdCP `push_notification_config` (task argument, MCP/A2A/REST) | [`mcp-webhook-payload.json`](#mcp) | -| A2A `TaskPushNotificationConfig` ([`CreateTaskPushNotificationConfig`](https://a2a-protocol.org/latest/specification/) RPC, or inline `task_push_notification_config` on `SendMessage`) | A2A native `Task` / `TaskStatusUpdateEvent` per A2A 1.0 §4.3.3 | +| A2A `TaskPushNotificationConfig` ([`CreateTaskPushNotificationConfig`](https://a2a-protocol.org/latest/specification/) RPC, or inline `configuration.taskPushNotificationConfig` on `SendMessage`) | A2A native `Task` / `TaskStatusUpdateEvent` per A2A 1.0 §4.3.3 | | Account-level `sync_accounts.accounts[].notification_configs[]` | Event-specific payloads such as `creative-status-changed-webhook.json`, `creative-assignment-changed-webhook.json`, `indicators-changed-webhook.json`, `account-status-changed-webhook.json`, or `wholesale-feed-webhook.json` | | Agent-level `sync_agent_notification_configs.notification_configs[]` | Event-specific AdCP payload schemas such as `capabilities-changed-webhook.json` | diff --git a/docs/building/concepts/protocol-comparison.mdx b/docs/building/concepts/protocol-comparison.mdx index 733bc47b32..975d04be18 100644 --- a/docs/building/concepts/protocol-comparison.mdx +++ b/docs/building/concepts/protocol-comparison.mdx @@ -129,35 +129,37 @@ if (adcpResponse.status === "submitted") { ``` ### A2A Async Pattern -```javascript -// Initial response carries an AdCP payload inside A2A task/artifact transport -{ - "status": "submitted", - "task_id": "adcp-task-456", - "contextId": "ctx-123", - "estimatedCompletionTime": "2025-01-23T10:00:00Z" -} -// Real-time updates via SSE -const events = new EventSource(`/tasks/${response.taskId}/events`); -events.onmessage = (event) => { - const update = JSON.parse(event.data); - console.log(`Status: ${update.status}, Message: ${update.message}`); -}; - -// Native webhook support -await a2a.send({ - message: { /* skill invocation */ }, - push_notification_config: { - webhook_url: "https://buyer.com/webhooks", - authentication: { - schemes: ["Bearer"], - credentials: "secret_token_min_32_chars" - } +The AdCP v3 profile returns an AdCP Submitted result inside a completed A2A +Task. The two IDs remain independent: + +```json +{ + "task": { + "id": "a2a-task-456", + "contextId": "ctx-123", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{ + "data": { + "status": "submitted", + "task_id": "adcp-task-456" + } + }] + }] } -}); +} ``` +Poll the durable operation with a fresh activated `{ skill: +"get_task_status", input: { task_id: "adcp-task-456" } }` invocation. Do not +poll `a2a-task-456`. For transport progress while a handler is executing, use +the official A2A `SendStreamingMessage` or `SubscribeToTask` operations and +consume their `StreamResponse` SSE frames. Durable AdCP webhooks are registered +as `push_notification_config` inside the typed task input; A2A +`configuration.taskPushNotificationConfig` tracks only the transport Task. + ## Context Management ### MCP: Manual Context @@ -176,13 +178,31 @@ async function callAdcp(request) { } ``` -### A2A: Automatic Context +### A2A: Message Context ```javascript -// A2A manages context automatically -const response1 = await a2a.send({ message: "Find video products" }); +// The server assigns contextId on the first typed invocation. +const response1 = await a2a.send({ + message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", + parts: [{ data: { + skill: "get_products", + input: { buying_mode: "brief", brief: "Find video products" } + } }] + } +}); + +// The next Message explicitly carries the returned contextId. const response2 = await a2a.send({ - contextId: response1.contextId, // Optional - A2A tracks this - message: "Focus on premium inventory" + message: { + messageId: crypto.randomUUID(), + contextId: response1.contextId, + role: "ROLE_USER", + parts: [{ data: { + skill: "get_products", + input: { buying_mode: "brief", brief: "Focus on premium video inventory" } + } }] + } }); ``` diff --git a/docs/creative/task-reference/sync_creatives.mdx b/docs/creative/task-reference/sync_creatives.mdx index fc5c65ff87..974bed0899 100644 --- a/docs/creative/task-reference/sync_creatives.mdx +++ b/docs/creative/task-reference/sync_creatives.mdx @@ -64,7 +64,7 @@ if ("errors" in validated && validated.errors && !("creatives" in validated) && } if ("status" in validated && validated.status === "submitted") { - // Whole sync queued asynchronously — poll tasks/get with task_id or await webhook + // Whole sync queued asynchronously — invoke get_task_status with task_id or await webhook console.log(`Sync queued as task ${validated.task_id}: ${validated.message ?? ""}`); } else if ("creatives" in validated) { console.log(`Synced ${validated.creatives.length} creatives`); @@ -108,7 +108,7 @@ async def main(): # Three-shape discriminated union: errors | submitted | creatives if getattr(result, 'status', None) == 'submitted': - # Whole sync queued asynchronously — poll tasks/get with task_id or await webhook + # Whole sync queued asynchronously — invoke get_task_status with task_id or await webhook print(f"Sync queued as task {result.task_id}: {getattr(result, 'message', '') or ''}") return @@ -391,7 +391,7 @@ Responses use discriminated unions — a response has exactly one of three shape **3. Submitted task envelope** — whole operation queued asynchronously (batch ingestion, governance review gating the sync): - `status` - Always `"submitted"` -- `task_id` - Handle for polling via `tasks/get` or receiving a webhook on completion +- `task_id` - Handle for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) on the A2A profile (or the advertised AdCP polling task on MCP), or receiving a webhook on completion - `message` - Optional human-readable explanation of the queue state The final per-creative `creatives` array lands on the task completion artifact, not on the submitted envelope. Per-item async review (one creative in `pending_review` while the rest of the sync resolves synchronously) belongs on the synchronous success branch with `status: "pending_review"` on that item, not here. @@ -895,7 +895,7 @@ Two distinct async patterns — match the right one to the agent's behavior: - `message` — optional human-readable explanation - No `creatives` array on this envelope -Poll `tasks/get` or wait for the webhook. The completion artifact carries the `creatives` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task. +Invoke [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) on the A2A profile (or the advertised AdCP polling task on MCP), or wait for the webhook. The completion artifact carries the `creatives` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task. **See:** [Webhooks](/docs/building/by-layer/L3/webhooks) for webhook configuration. diff --git a/docs/governance/content-standards/tasks/calibrate_content.mdx b/docs/governance/content-standards/tasks/calibrate_content.mdx index 87cdde8800..fd917d795a 100644 --- a/docs/governance/content-standards/tasks/calibrate_content.mdx +++ b/docs/governance/content-standards/tasks/calibrate_content.mdx @@ -107,7 +107,9 @@ An artifact represents content context where ad placements occur - identified by ## Dialogue Flow -Calibration supports back-and-forth dialogue using the protocol's conversation management. The seller sends content, the verification agent responds with an evaluation and explanation, and the seller can respond with questions or try different content - all within the same conversation context. +Calibration supports a sequence of typed evaluations using A2A conversation +correlation. Each turn remains a complete `calibrate_content` invocation; a +`contextId` groups the evaluations but does not make free text authoritative. ### A2A Example @@ -115,14 +117,16 @@ Calibration supports back-and-forth dialogue using the protocol's conversation m // Seller sends artifact to evaluate const response1 = await a2a.send({ message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", parts: [{ - kind: "data", data: { skill: "calibrate_content", - parameters: { + input: { + idempotency_key: crypto.randomUUID(), standards_id: "nike_brand_safety", artifact: { - property_id: { type: "domain", value: "reddit.com" }, + property_rid: "01916f3a-a1d3-7000-8000-000000000040", artifact_id: "r_news_politics_123", assets: [ { type: "text", role: "title", content: "Political News Article" } @@ -135,30 +139,46 @@ const response1 = await a2a.send({ }); // Response: verdict=fail with feature breakdown -// Seller asks follow-up question about the decision +// Seller tests a more explicitly balanced version in the same context. const response2 = await a2a.send({ - contextId: response1.contextId, message: { + messageId: crypto.randomUUID(), + contextId: response1.contextId, + role: "ROLE_USER", parts: [{ - kind: "text", - text: "This is factual news, not opinion. Should balanced journalism be excluded?" + data: { + skill: "calibrate_content", + input: { + idempotency_key: crypto.randomUUID(), + standards_id: "nike_brand_safety", + artifact: { + property_rid: "01916f3a-a1d3-7000-8000-000000000040", + artifact_id: "r_news_balanced_124", + assets: [ + { type: "text", role: "title", content: "Balanced Political News Analysis" } + ] + } + } + } }] } }); -// Verification agent clarifies that brand policy excludes ALL political content +// Verification agent still reports the policy boundary through typed output. // Seller tries different artifact const response3 = await a2a.send({ - contextId: response1.contextId, message: { + messageId: crypto.randomUUID(), + contextId: response1.contextId, + role: "ROLE_USER", parts: [{ - kind: "data", data: { skill: "calibrate_content", - parameters: { + input: { + idempotency_key: crypto.randomUUID(), standards_id: "nike_brand_safety", artifact: { - property_id: { type: "domain", value: "reddit.com" }, + property_rid: "01916f3a-a1d3-7000-8000-000000000040", artifact_id: "r_running_tips_456", assets: [ { type: "text", role: "title", content: "Running Tips" } @@ -179,7 +199,7 @@ const response3 = await a2a.send({ const response1 = await mcp.call('calibrate_content', { standards_id: "nike_brand_safety", artifact: { - property_id: { type: "domain", value: "reddit.com" }, + property_rid: "01916f3a-a1d3-7000-8000-000000000040", artifact_id: "r_news_politics_123", assets: [ { type: "text", role: "title", content: "Political News Article" } @@ -193,7 +213,7 @@ const response2 = await mcp.call('calibrate_content', { context_id: response1.context_id, standards_id: "nike_brand_safety", artifact: { - property_id: { type: "domain", value: "reddit.com" }, + property_rid: "01916f3a-a1d3-7000-8000-000000000040", artifact_id: "r_news_politics_123", assets: [ { type: "text", role: "title", content: "Political News Article" } @@ -207,7 +227,7 @@ const response3 = await mcp.call('calibrate_content', { context_id: response1.context_id, standards_id: "nike_brand_safety", artifact: { - property_id: { type: "domain", value: "reddit.com" }, + property_rid: "01916f3a-a1d3-7000-8000-000000000040", artifact_id: "r_running_tips_456", assets: [ { type: "text", role: "title", content: "Running Tips" } diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 488ea38363..e02eff33d7 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -343,14 +343,14 @@ Terminal and submitted responses never include `warnings`. Each success warning ### Submitted Response -Returned when the buy cannot be confirmed synchronously — e.g., guaranteed buys awaiting IO signing, governance review queued, or batched processing. The completion artifact (delivered via `tasks/get` or push-notification webhook) carries `media_buy_id` and `packages`. +Returned when the buy cannot be confirmed synchronously — e.g., guaranteed buys awaiting IO signing, governance review queued, or batched processing. The completion artifact (delivered through AdCP polling or a push-notification webhook) carries `media_buy_id` and `packages`. A2A profile callers poll with [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json); MCP sellers may also expose the legacy AdCP `tasks/get` name. There is no proposal-specific acceptance webhook. Proposal execution that needs human approval, IO signing, or asynchronous processing uses this same submitted task envelope and the standard task/webhook completion path. | Field | Description | |-------|-------------| | `status` | Literal `"submitted"` — discriminates this shape from the sync success branch, which uses `media_buy_status` for lifecycle state. | -| `task_id` | Handle the buyer polls with `tasks/get` or receives on webhook callbacks. | +| `task_id` | Handle the buyer polls with `get_task_status` on the A2A profile (or the advertised AdCP polling task on MCP), or receives on webhook callbacks. | | `message` | Optional human-readable explanation (e.g., "Awaiting IO signature from sales team"). | | `errors` | Optional advisory warnings (non-blocking). Terminal failures belong in the Error Response. | @@ -362,7 +362,7 @@ The choice between `submitted` and synchronous success is **per-call**, driven b Sellers MUST return `submitted` when: -- The request references one or more products with `delivery_type: "guaranteed"` **and** the seller declares the `requires_io_approval` capability — the human-approval handshake cannot complete inside the response. The completion artifact is delivered via `tasks/get` or webhook once IO signing finishes. +- The request references one or more products with `delivery_type: "guaranteed"` **and** the seller declares the `requires_io_approval` capability — the human-approval handshake cannot complete inside the response. The completion artifact is delivered through AdCP polling (`get_task_status` on the A2A profile) or webhook once IO signing finishes. - The request triggers a seller-side governance review that cannot complete synchronously (e.g., manual brand-safety review for a regulated vertical). - The request enters a batched-processing queue the seller cannot drain inside the response timeout. @@ -1528,19 +1528,19 @@ const response = await session.call('create_media_buy', ```javascript test=false const response = await a2a.send({ message: { + messageId: crypto.randomUUID(), + role: 'ROLE_USER', parts: [{ - kind: 'data', data: { skill: 'create_media_buy', - parameters: { - brand: { domain: 'acmecorp.com' }, - packages: [ - { - product_id: 'prod_ctv_sports', - pricing_option_id: 'cpm_fixed', - budget: 50000 - } - ] + input: { + idempotency_key: '550e8400-e29b-41d4-a716-446655440010', + 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' } } }] @@ -1551,122 +1551,87 @@ const response = await a2a.send({ **Response:** ```json { - "status": "completed", - "taskId": "task_123", - "contextId": "ctx_456", - "artifacts": [{ - "parts": [ - { "text": "Media buy created successfully" }, - { - "data": { - "media_buy_id": "mb_12345", - "confirmed_at": "2025-06-01T10:00:00Z", - "creative_deadline": "2025-06-15T23:59:59Z", - "revision": 1, - "packages": [ - { - "package_id": "pkg_001", - } - ] - } - } - ] - }] -} -``` - -### Processing (`working`) - -Task is actively processing. Use SSE streaming or poll for updates. - -**Initial response:** -```json -{ - "status": "working", - "taskId": "task_789", - "contextId": "ctx_456" -} -``` - -**SSE status update:** -```json -{ - "taskId": "task_789", - "status": { - "state": "working", - "message": { + "task": { + "id": "task_123", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "media-buy-result", "parts": [ - { "text": "Validating inventory availability..." }, + { "text": "Media buy created successfully" }, { "data": { - "percentage": 50, - "current_step": "inventory_check" + "status": "completed", + "media_buy_id": "mb_12345", + "confirmed_at": "2025-06-01T10:00:00Z", + "creative_deadline": "2025-06-15T23:59:59Z", + "revision": 1, + "packages": [{ "package_id": "pkg_001" }] } } ] - } + }] } } ``` -### Long-Running (`submitted`) +### Processing (`working`) + +To receive transport progress while the AdCP handler is running, use A2A 1.0 +`SendStreamingMessage` or `SubscribeToTask` and consume `StreamResponse` SSE +frames. The non-streaming helper shown here uses ordinary `SendMessage`, which +waits for a terminal or interrupted A2A state unless +`configuration.returnImmediately` is explicitly enabled. + +### Long-Running AdCP Operation (`submitted`) -**Request with push notification:** +**Profile request:** ```javascript test=false const response = await a2a.send({ message: { + messageId: crypto.randomUUID(), + role: 'ROLE_USER', parts: [{ - kind: 'data', data: { skill: 'create_media_buy', - parameters: { - packages: [{ budget: 500000 }] // Triggers approval + input: { + idempotency_key: '550e8400-e29b-41d4-a716-446655440011', + account: { account_id: 'acc_demo_001' }, + brand: { domain: 'brand.example' }, + proposal_id: 'proposal_requires_approval_001', + total_budget: { amount: 500000, currency: 'USD' }, + start_time: 'asap', + end_time: '2027-06-30T23:59:59Z' } } }] - }, - pushNotificationConfig: { - url: 'https://your-app.com/webhooks/a2a', - authentication: { - schemes: ['bearer'], - credentials: 'your_webhook_secret' - } } }); ``` -**Initial response:** -```json -{ - "status": "submitted", - "taskId": "task_abc", - "contextId": "ctx_456" -} -``` - -**Webhook POST (Task) when completed:** +**Completed A2A invocation containing the AdCP Submitted response:** ```json { - "id": "task_abc", - "contextId": "ctx_456", - "status": { - "state": "completed", - "message": { - "parts": [ - { "text": "Media buy approved and created" }, - { - "data": { - "media_buy_id": "mb_67890", - "packages": [{ "package_id": "pkg_002" }] - } + "task": { + "id": "a2a-task-abc", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{ + "data": { + "status": "submitted", + "task_id": "adcp-task-abc", + "message": "Awaiting budget approval" } - ] - }, - "timestamp": "2025-01-22T14:30:00Z" + }] + }] } } ``` +Poll the durable operation with a new activated invocation of [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) using `adcp-task-abc`. Do not poll `a2a-task-abc`; that A2A transport invocation is already complete. The AdCP handle is not duplicated in artifact metadata. + ### Input Required (`input-required`) Task is paused waiting for clarification or approval. @@ -1674,35 +1639,49 @@ Task is paused waiting for clarification or approval. **Response:** ```json { - "status": "input-required", - "taskId": "task_def", - "contextId": "ctx_456", - "artifacts": [{ - "parts": [ - { "text": "The requested budget exceeds your pre-approved limit. Please confirm you want to proceed with $500K spend." }, - { - "data": { - "reason": "APPROVAL_REQUIRED", - "errors": [ - { - "code": "BUDGET_EXCEEDS_LIMIT", - "message": "Requested budget exceeds pre-approved limit", - "field": "total_budget" - } - ] - } + "task": { + "id": "task_def", + "contextId": "ctx_456", + "status": { + "state": "TASK_STATE_INPUT_REQUIRED", + "message": { + "messageId": "msg-approval-001", + "taskId": "task_def", + "contextId": "ctx_456", + "role": "ROLE_AGENT", + "parts": [ + { "text": "The requested budget exceeds your pre-approved limit. Please confirm you want to proceed with $500K spend." }, + { "data": { "reason": "APPROVAL_REQUIRED" } } + ] } - ] - }] + } + } } ``` **Follow-up to approve:** ```javascript test=false await a2a.send({ - contextId: 'ctx_456', // Continue the conversation message: { - parts: [{ kind: 'text', text: 'Yes, I confirm the $500K budget' }] + messageId: crypto.randomUUID(), + taskId: 'task_def', + contextId: 'ctx_456', + role: 'ROLE_USER', + parts: [{ + data: { + skill: 'create_media_buy', + input: { + idempotency_key: '550e8400-e29b-41d4-a716-446655440011', + account: { account_id: 'acc_demo_001' }, + brand: { domain: 'brand.example' }, + proposal_id: 'proposal_requires_approval_001', + total_budget: { amount: 500000, currency: 'USD' }, + start_time: 'asap', + end_time: '2027-06-30T23:59:59Z', + ext: { 'buyer.example': { approval_confirmed: true } } + } + } + }] } }); ``` @@ -1712,24 +1691,26 @@ await a2a.send({ **Response:** ```json { - "status": "failed", - "taskId": "task_xyz", - "artifacts": [{ - "parts": [ - { "text": "Failed to create media buy" }, - { - "data": { - "errors": [ - { + "task": { + "id": "task_xyz", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_FAILED" }, + "artifacts": [{ + "artifactId": "adcp-error", + "parts": [ + { "text": "Failed to create media buy" }, + { + "data": { + "adcp_error": { "code": "INSUFFICIENT_INVENTORY", "message": "Requested targeting yields no available impressions", "suggestion": "Expand geographic targeting" } - ] + } } - } - ] - }] + ] + }] + } } ``` diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 4881d954fa..bc5fde0485 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -1541,12 +1541,11 @@ The rejection arm carries no `products`, `proposals`, `incomplete`, `filter_diag ```json { - "status": { "state": "completed" }, + "status": { "state": "TASK_STATE_COMPLETED" }, "artifacts": [ { "parts": [ { - "kind": "data", "data": { "status": "rejected", "reason": "The requested budget is below the minimum for this inventory." @@ -1822,17 +1821,17 @@ Response (200 OK): #### Immediate Completion (Most Common) -```json -POST /api/a2a +Send the profile URI in `A2A-Extensions` and use the structured invocation body: +```json { "message": { - "role": "user", + "messageId": "msg-get-products-001", + "role": "ROLE_USER", "parts": [{ - "kind": "data", "data": { "skill": "get_products", - "parameters": { + "input": { "idempotency_key": "550e8400-e29b-41d4-a716-446655441034", "buying_mode": "brief", "brief": "CTV inventory for sports audience", @@ -1842,23 +1841,24 @@ POST /api/a2a }] } } +``` -Response (200 OK): +The non-streaming `SendMessage` response selects its `task` branch. The Task is +completed and carries the direct AdCP response in its artifact: + +```json { - "id": "task_123", - "contextId": "ctx_456", - "artifact": { - "kind": "data", - "data": { - "products": [...] - } - }, - "status": { - "state": "completed", - "message": { - "role": "agent", - "parts": [{ "text": "Found 3 products matching your requirements" }] - } + "task": { + "id": "a2a-task-123", + "contextId": "ctx_456", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [ + { "text": "Found 3 products matching your requirements" }, + { "data": { "status": "completed", "cache_scope": "account", "products": [] } } + ] + }] } } ``` @@ -1868,134 +1868,142 @@ Response (200 OK): Real-time updates via SSE when clarification is needed: ```json -// Initial response { - "id": "task_789", - "contextId": "ctx_123", - "status": { - "state": "input-required", - "message": { - "role": "agent", - "parts": [ - { "text": "I need a bit more information. What's your budget range and campaign duration?" }, - { - "data": { - "reason": "CLARIFICATION_NEEDED", - "suggestions": ["$50K-$100K", "1 month", "Q1 2024"] + "task": { + "id": "task_789", + "contextId": "ctx_123", + "status": { + "state": "TASK_STATE_INPUT_REQUIRED", + "message": { + "messageId": "msg-clarification-001", + "taskId": "task_789", + "contextId": "ctx_123", + "role": "ROLE_AGENT", + "parts": [ + { "text": "I need a bit more information. What's your budget range and campaign duration?" }, + { + "data": { + "reason": "CLARIFICATION_NEEDED", + "suggestions": ["$50K-$100K", "1 month", "Q1 2024"] + } } - } - ] + ] + } } } } +``` -// Send follow-up -POST /api/a2a +Send a new activated profile invocation in the same context, with the refined typed request: +```json { - "contextId": "ctx_123", "message": { - "role": "user", - "parts": [{ "text": "Budget is $75K for a 3-week campaign in March" }] + "messageId": "msg-get-products-002", + "taskId": "task_789", + "contextId": "ctx_123", + "role": "ROLE_USER", + "parts": [{ + "data": { + "skill": "get_products", + "input": { + "buying_mode": "brief", + "brief": "CTV sports inventory with a $75K budget for three weeks in March" + } + } + }] } } +``` + +The resulting Task uses the same completed-artifact mapping as the immediate response: -// SSE update: task completed +```json { - "id": "task_789", - "contextId": "ctx_123", - "artifact": { - "kind": "data", - "data": { "products": [...] } - }, - "status": { - "state": "completed", - "message": { - "role": "agent", - "parts": [{ "text": "Perfect! Found 5 products within your budget" }] - } + "task": { + "id": "task_789", + "contextId": "ctx_123", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [ + { "text": "Found 5 products within your budget" }, + { "data": { "status": "completed", "cache_scope": "account", "products": [] } } + ] + }] } } ``` -#### Complex Search (With Webhook and Polling) +#### Complex Search (AdCP Submitted and Polling) -For A2A, push notifications use the transport-level `configuration.pushNotificationConfig` field. Do not put snake_case `push_notification_config` inside the skill parameters. The A2A task id remains valid for task polling. +Activate the [AdCP A2A Profile Extension v3](/docs/building/by-layer/L0/a2a-profile-extension) on the request: -```json -POST /api/a2a +```http +POST /api/a2a HTTP/1.1 +A2A-Version: 1.0 +A2A-Extensions: https://adcontextprotocol.org/extensions/adcp/v3 +Content-Type: application/json +``` +```json { "message": { - "role": "user", + "messageId": "msg-get-products-003", + "role": "ROLE_USER", "parts": [{ - "kind": "data", "data": { "skill": "get_products", - "parameters": { + "input": { "idempotency_key": "550e8400-e29b-41d4-a716-446655441035", "buying_mode": "brief", - "brief": "Premium inventory across all formats for luxury automotive brand", + "brief": "Premium inventory across all formats for a luxury automotive brand", "brand": { "domain": "acmecorp.com" } } } }] - }, - "configuration": { - "pushNotificationConfig": { - "url": "https://buyer.com/webhooks/a2a/get_products", - "authentication": { - "schemes": ["bearer"], - "credentials": "secret_token_32_chars" - } - } } } +``` -Response (200 OK): +If custom curation is queued, the A2A invocation is still complete. The AdCP Submitted response and its durable handle are inside the artifact DataPart: + +```json { - "id": "task_456", - "contextId": "ctx_789", - "status": { - "state": "submitted", - "message": { - "role": "agent", - "parts": [ - { "text": "Custom curation queued; typical turnaround 10-30 minutes" }, - { - "data": { - "estimated_completion": "2025-01-22T10:30:00Z" - } + "task": { + "id": "a2a-task-456", + "contextId": "ctx_789", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{ + "data": { + "status": "submitted", + "task_id": "adcp-task-789", + "message": "Custom curation queued; typical turnaround 10-30 minutes" } - ] - } + }] + }] } } +``` + +Later, send a fresh activated invocation of `get_task_status`. Do not poll `a2a-task-456`; it identifies the already-completed transport invocation. -// Later, poll the A2A task, or receive webhook POST to https://buyer.com/webhooks/a2a/get_products +```json { - "id": "task_456", - "contextId": "ctx_789", - "artifact": { - "kind": "data", - "data": { - "products": [...] - } - }, - "status": { - "state": "completed", - "message": { - "role": "agent", - "parts": [ - { "text": "Found 12 premium products across all formats" }, - { - "data": { - "products": [...] - } + "message": { + "messageId": "msg-get-products-poll-001", + "role": "ROLE_USER", + "parts": [{ + "data": { + "skill": "get_task_status", + "input": { + "task_id": "adcp-task-789", + "include_result": true } - ] - }, - "timestamp": "2025-01-22T10:30:00Z" + } + }] } } ``` @@ -2010,7 +2018,7 @@ Response (200 OK): | `completed` | Search finished successfully | Process the product results | | `input-required` | Need clarification on the brief | Answer the question and continue | | `working` | Searching across multiple sources | Wait on the open connection / transport progress stream | -| `submitted` | Custom curation queued | Poll `get_task_status` (legacy `tasks/get`) with `task_id`; also wait for webhook notification if `push_notification_config` / A2A `configuration.pushNotificationConfig` was accepted | +| `submitted` | Custom curation queued | Poll `get_task_status` with the AdCP `task_id`; on MCP, the seller may advertise the legacy AdCP `tasks/get` alias | | `rejected` | Seller understood the brief but deliberately declined it | Read the sanitized reason; revise and resubmit only when `suggestions[]` offers a recovery path | | `failed` | Search couldn't complete | Check error message, adjust brief | diff --git a/docs/media-buy/task-reference/sync_audiences.mdx b/docs/media-buy/task-reference/sync_audiences.mdx index 601ae5c33c..6a967f9d40 100644 --- a/docs/media-buy/task-reference/sync_audiences.mdx +++ b/docs/media-buy/task-reference/sync_audiences.mdx @@ -54,7 +54,7 @@ const validated = SyncAudiencesResponseSchema.parse(result.data); // Three-shape discriminated union: errors | submitted | audiences if ("status" in validated && validated.status === "submitted") { - // Whole sync queued asynchronously — poll tasks/get with task_id or await webhook + // Whole sync queued asynchronously — invoke get_task_status with task_id or await webhook console.log(`Sync queued as task ${validated.task_id}: ${validated.message ?? ""}`); } else if ("errors" in validated && validated.errors && !("audiences" in validated)) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); @@ -94,7 +94,7 @@ async def main(): # Three-shape discriminated union: errors | submitted | audiences if getattr(result, 'status', None) == 'submitted': - # Whole sync queued asynchronously — poll tasks/get with task_id or await webhook + # Whole sync queued asynchronously — invoke get_task_status with task_id or await webhook print(f"Sync queued as task {result.task_id}: {getattr(result, 'message', '') or ''}") return @@ -166,7 +166,7 @@ Responses use discriminated unions — a response has exactly one of three shape **3. Submitted task envelope** — whole operation queued asynchronously (batch ingestion, governance-gated upload, clean-room flows where the seller cannot return per-audience results before the response is emitted): - `status` — Always `"submitted"` -- `task_id` — Handle for polling via `tasks/get` or receiving a webhook on completion +- `task_id` — Handle for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) on the A2A profile (or the advertised AdCP polling task on MCP), or receiving a webhook on completion - `message` — Optional human-readable explanation of the queue state The final per-audience `audiences` array lands on the task completion artifact, not on the submitted envelope. Per-audience asynchronous matching (one audience in `processing` while the rest of the sync resolves synchronously) belongs on the synchronous success branch with `status: "processing"` on that item, not on the submitted envelope. Matching latency on the per-audience [audience-status](#audience-status) enum is the common case; the submitted envelope is for the less-common operation-level async case. @@ -516,7 +516,7 @@ Sellers MUST emit `too_small` whenever `matched_count < minimum_size`. Returning **Webhook (recommended)**: Configure `push_notification_config` at the protocol level before uploading. The task stays active while the seller's platform matches members. When matching completes, the task completes and the webhook fires with the final result — `status: "ready"` or `status: "too_small"`. Check [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) → `audience_targeting.matching_latency_hours` to set realistic expectations (typically 1–48 hours). -**Polling fallback**: If not using webhooks, poll with discovery-only calls (omit `audiences`) no more frequently than every 15 minutes. Use `tasks/get` with the `task_id` to check task status — the task will be `submitted` while matching is in progress and `completed` when the audience is ready or too small. +**Polling fallback**: If not using webhooks, poll with discovery-only calls (omit `audiences`) no more frequently than every 15 minutes. A2A profile callers use [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) with the `task_id`; MCP callers use the advertised AdCP polling task. The task will be `submitted` while matching is in progress and `completed` when the audience is ready or too small. **Agent workflow**: Upload with `push_notification_config` set. Externalize the `audience_id` and `account_id` before the session ends. When the webhook fires with `status: "ready"`, resume and proceed to [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). @@ -532,7 +532,7 @@ Two distinct async patterns — match the right one to the seller's behavior: - `message` — optional human-readable explanation - No `audiences` array on this envelope -Poll `tasks/get` or wait for the webhook. The completion artifact carries the `audiences` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task. +Invoke [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) on the A2A profile (or the advertised AdCP polling task on MCP), or wait for the webhook. The completion artifact carries the `audiences` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task. **See:** [Webhooks](/docs/building/by-layer/L3/webhooks) for webhook configuration. diff --git a/docs/protocol/calling-an-agent.mdx b/docs/protocol/calling-an-agent.mdx index ee835a2a87..da2a202502 100644 --- a/docs/protocol/calling-an-agent.mdx +++ b/docs/protocol/calling-an-agent.mdx @@ -77,12 +77,12 @@ A mutating tool can return one of three shapes: AdCP task state is an **application-layer** contract. MCP and A2A may wrap, stream, or transport an AdCP response, but their native task mechanisms do not replace the AdCP `task_id`, status values, webhook payloads, or polling/reconciliation surfaces. A transport task can complete after delivering an AdCP response whose payload still says `status: 'submitted'`. -When you see `status: 'submitted'`, the work is **not** complete. In 3.x, poll via the legacy AdCP `tasks/get` surface using the returned `task_id`. Sellers MAY also advertise the non-colliding [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) alias; callers MAY use that alias when it appears in discovery. Both AdCP polling names use the same snake_case payload shape, including the optional `account` scope for multi-account credentials. Do not confuse either AdCP polling shape with transport-native MCP/A2A `tasks/get`, which uses the transport's own task wire shape. +When you see `status: 'submitted'`, the work is **not** complete. Under the [AdCP A2A Profile Extension v3](/docs/building/by-layer/L0/a2a-profile-extension), the caller MUST poll by sending fresh structured invocations of [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) with the returned AdCP `task_id`. The enclosing A2A Task is already completed and MUST NOT be polled as a proxy for the AdCP operation. MCP implementations may continue to expose the legacy AdCP `tasks/get` surface; both AdCP polling names use the same snake_case payload shape, including the optional `account` scope for multi-account credentials. Pass `include_result: true` when polling so the seller includes the completion payload once status transitions to `completed`: ```json -// tasks/get request (same payload as optional get_task_status alias) +// get_task_status request { "task_id": "task_456", "include_result": true, @@ -93,7 +93,7 @@ Pass `include_result: true` when polling so the seller includes the completion p } } -// tasks/get response — completed +// get_task_status response — completed { "task_id": "task_456", "task_type": "create_media_buy", @@ -157,16 +157,16 @@ Every validation failure produces an envelope shaped like: | `keyword: 'type'` or `additionalProperties` at `/budget` | Sent `{amount, currency}` | `budget` is a number. Currency is implied by `pricing_option_id`. | | `enum` at `/format_kind` | Sent a non-canonical format kind | Choose a registered canonical kind or use a valid `custom` declaration. | | `keyword: 'enum'` at `/destinations/*/type` | Made-up destination type | Use `'platform'` (with `platform`) or `'agent'` (with `agent_url`). | -| Response carries `status: 'submitted'` and `task_id` | Async — work is queued, NOT done | Poll with legacy `tasks/get`, or [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) when the seller advertises the alias. | +| Response carries `status: 'submitted'` and `task_id` | Async — work is queued, NOT done | On the AdCP v3 A2A profile, invoke [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json). On MCP, use the advertised AdCP polling task. | ## Transport notes - **MCP**: `tools/call` with `{ name: 'tool_name', arguments: {...} }`. Read `structuredContent` for the typed response. -- **A2A**: `message/send` with a `DataPart` of shape `{ skill: 'tool_name', input: {...} }`. The typed response is at `task.artifacts[0].parts[0].data`. +- **A2A 1.0**: advertise and activate `https://adcontextprotocol.org/extensions/adcp/v3`, then Send Message with exactly one invocation DataPart shaped `{ skill: 'tool_name', input: {...} }`; optional TextParts are advisory. Read the authoritative response DataPart from the completed Task artifact. Both transports share idempotency, error shape, schema enforcement, and handler semantics. If a call works on one, the equivalent call works on the other. -A common trap: **A2A `Task.status.state: 'completed'` is not the same as AdCP completion.** A2A task state describes the transport call lifecycle; AdCP-level completion is in the artifact's payload (`structuredContent.status` or `data.status`). A `completed` A2A task can still carry a `submitted` AdCP response. It also carries structured business outcomes that are not transport failures, such as `GetProductsRejected`: A2A remains `completed`, while the artifact payload contains `status: "rejected"`, `reason`, and optional `suggestions[]`. +A common trap: **A2A `Task.status.state: 'completed'` is not the same as AdCP completion.** A2A task state describes the transport invocation; AdCP-level completion is in the artifact DataPart. A `completed` A2A task can carry a `submitted` AdCP response, whose `task_id` remains only in that DataPart and is polled through [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json). It also carries structured business outcomes that are not transport failures, such as `GetProductsRejected`: A2A remains `completed`, while the artifact payload contains `status: "rejected"`, `reason`, and optional `suggestions[]`. ## Related diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 183a85c040..c60655fc75 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -47,24 +47,24 @@ Discover a seller's protocol support and capabilities across all AdCP protocols. ## Tool-Based Discovery -AdCP uses native MCP/A2A tool discovery. **The presence of `get_adcp_capabilities` in an agent's tool list indicates AdCP support.** +AdCP uses native MCP tool discovery and the [AdCP A2A Profile Extension v3](/docs/building/by-layer/L0/a2a-profile-extension) on A2A 1.0. **The presence of `get_adcp_capabilities` in an agent's tool or skill list indicates that runtime AdCP discovery is available.** ``` Discovery Flow: -1. Browse agent's tool list (MCP) or skills (A2A) -2. See 'get_adcp_capabilities' tool → Agent supports AdCP +1. Browse the agent's tool list (MCP), or find and activate the versioned AdCP profile in AgentCard.capabilities.extensions[] (A2A) +2. See the get_adcp_capabilities tool/skill → Runtime AdCP discovery is available 3. Call get_adcp_capabilities → Get version, protocols, features, capabilities 4. Proceed based on returned capabilities ``` This approach: -- Uses standard MCP/A2A mechanisms (no custom extensions) +- Uses native MCP discovery or A2A 1.0's standard extension mechanism - Always returns current capabilities (not stale metadata) - Single source of truth for all capability information :::note -The agent card extension (`adcp-extension.json`) has been removed in v3. Use tool-based discovery instead. +The A2A profile declaration identifies only the wire binding. Its `params` member is omitted or empty. Do not place AdCP versions, domains, or feature flags there: `get_adcp_capabilities` remains the single runtime authority for those values. The unversioned v2 `adcp-extension.json` capability payload remains removed. ::: ## Version Negotiation diff --git a/docs/protocol/required-tasks.mdx b/docs/protocol/required-tasks.mdx index 9419f53e72..f38910d33b 100644 --- a/docs/protocol/required-tasks.mdx +++ b/docs/protocol/required-tasks.mdx @@ -24,7 +24,7 @@ Every AdCP agent, regardless of protocol, implements: Caller-scope RBAC introspection is not a standalone task. Sellers that support scope introspection return a per-account `authorization` object on [`sync_accounts`](/docs/accounts/tasks/sync_accounts) and [`list_accounts`](/docs/accounts/tasks/list_accounts) responses. See [Accounts Protocol — Caller authorization](/docs/accounts/overview#caller-authorization). -AdCP task lifecycle state is application-layer state, not MCP-native or A2A-native task state. 3.x sellers MAY advertise non-colliding AdCP aliases for polling and reconciliation: [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) for legacy `tasks/get`, and [`list_tasks`](https://adcontextprotocol.org/schemas/v3/protocol/list-tasks-request.json) for legacy `tasks/list`. These aliases are optional compatibility surfaces in 3.x; callers MUST continue to support the legacy names through the 3.x line. Alias and legacy request payloads use the same snake_case shape, including the optional `account` scope used to keep task visibility within the caller's authenticated account + principal pair. +AdCP task lifecycle state is application-layer state, not MCP-native or A2A-native task state. 3.x sellers MAY advertise non-colliding AdCP aliases for polling and reconciliation: [`get_task_status`](https://adcontextprotocol.org/schemas/v3/protocol/get-task-status-request.json) for legacy `tasks/get`, and [`list_tasks`](https://adcontextprotocol.org/schemas/v3/protocol/list-tasks-request.json) for legacy `tasks/list`. These aliases remain optional compatibility surfaces generally in 3.x; callers MUST continue to support the legacy names through the 3.x line. The [AdCP A2A Profile Extension v3](/docs/building/by-layer/L0/a2a-profile-extension) is the scoped exception: an agent that can return an AdCP Submitted response on that profile MUST expose `get_task_status`, and the A2A caller uses that non-colliding task. Alias and legacy request payloads use the same snake_case shape, including the optional `account` scope used to keep task visibility within the caller's authenticated account + principal pair. ## Media Buy Protocol diff --git a/docs/reference/test-vectors/index.mdx b/docs/reference/test-vectors/index.mdx index f6bdeb22b9..0d8028c991 100644 --- a/docs/reference/test-vectors/index.mdx +++ b/docs/reference/test-vectors/index.mdx @@ -37,6 +37,7 @@ SDKs SHOULD fetch versioned paths where available and record the version under t | [`transport-error-mapping`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/transport-error-mapping.json) | Transport-layer error envelope shapes: the JSON-RPC (`error.code` / `data`) and A2A (task `status.message`) carriers for each documented AdCP transport error | `static/test-vectors/transport-error-mapping.json` | [`/test-vectors/transport-error-mapping.json`](https://adcontextprotocol.org/test-vectors/transport-error-mapping.json) | | [`mcp-response-extraction`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/mcp-response-extraction.json) | Client extraction of the AdCP payload from MCP `tools/call` envelopes | `static/test-vectors/mcp-response-extraction.json` | [`/test-vectors/mcp-response-extraction.json`](https://adcontextprotocol.org/test-vectors/mcp-response-extraction.json) | | [`a2a-response-extraction`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/a2a-response-extraction.json) | Client extraction of the AdCP payload from A2A task statuses and artifacts | `static/test-vectors/a2a-response-extraction.json` | [`/test-vectors/a2a-response-extraction.json`](https://adcontextprotocol.org/test-vectors/a2a-response-extraction.json) | +| [`a2a-profile-extension-v3`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/a2a-profile-extension-v3.json) | A2A 1.0 profile advertisement and activation, `{ skill, input }` invocation, advisory TextParts, completed-Task mapping for AdCP Submitted responses, metadata-duplication rejection, and `get_task_status` polling | `static/test-vectors/a2a-profile-extension-v3.json` | [`/test-vectors/a2a-profile-extension-v3.json`](https://adcontextprotocol.org/test-vectors/a2a-profile-extension-v3.json) | | [`webhook-payload-extraction`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/webhook-payload-extraction.json) | Receiver-side format detection and payload extraction for inbound AdCP webhooks | `static/test-vectors/webhook-payload-extraction.json` | [`/test-vectors/webhook-payload-extraction.json`](https://adcontextprotocol.org/test-vectors/webhook-payload-extraction.json) | | [`webhook-hmac-sha256`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/webhook-hmac-sha256.json) *(legacy)* | HMAC-SHA-256 signature computation and byte-equality invariants for the legacy HMAC webhook profile. Deprecated in 3.x, removed in 4.0 per [Webhook callbacks](/docs/building/by-layer/L3/webhooks#legacy-hmac-sha256-fallback-deprecated); new integrations use `webhook-signing` | `static/test-vectors/webhook-hmac-sha256.json` | [`/test-vectors/webhook-hmac-sha256.json`](https://adcontextprotocol.org/test-vectors/webhook-hmac-sha256.json) | | [`canonical-image-pixel-ratio`](https://github.com/adcontextprotocol/adcp/blob/main/static/test-vectors/canonical-image-pixel-ratio.json) | Canonical image logical-size versus intrinsic-pixel validation, top-level/slot density intersection, accepted and required rendition sets, mismatch failures, and parameterized legacy projection | `static/test-vectors/canonical-image-pixel-ratio.json` | [`/test-vectors/canonical-image-pixel-ratio.json`](https://adcontextprotocol.org/test-vectors/canonical-image-pixel-ratio.json) | diff --git a/docs/signals/tasks/activate_signal.mdx b/docs/signals/tasks/activate_signal.mdx index c91830a48c..08eebc9a26 100644 --- a/docs/signals/tasks/activate_signal.mdx +++ b/docs/signals/tasks/activate_signal.mdx @@ -245,26 +245,22 @@ After polling for completion: ### A2A Request #### Natural Language Invocation -```javascript -await a2a.send({ - message: { - parts: [{ - kind: "text", - text: "Please activate the luxury_auto_intenders signal on The Trade Desk for account agency-123-ttd." - }] - } -}); -``` + +Text-only invocation requires a separate generic A2A interface whose Agent +Card does not mark the AdCP v3 profile as required. It is not an AdCP profile +invocation. #### Explicit Skill Invocation ```javascript await a2a.send({ message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", parts: [{ - kind: "data", data: { skill: "activate_signal", - parameters: { + input: { + idempotency_key: crypto.randomUUID(), signal_agent_segment_id: "luxury_auto_intenders", pricing_option_id: "po_cpm_usd", destinations: [{ @@ -279,45 +275,46 @@ await a2a.send({ }); ``` -### A2A Response (with streaming) -Initial response: +### A2A Response + +A non-streaming `SendMessage` response selects the completed Task branch. A +client that needs transport progress uses the official `SendStreamingMessage` +or `SubscribeToTask` operation and consumes `StreamResponse` SSE frames. + ```json { - "taskId": "task-signal-001", - "status": { "state": "working" } + "task": { + "id": "task-signal-001", + "contextId": "ctx-signals-123", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ + "artifactId": "artifact-signal-activation-abc123", + "name": "signal_activation_result", + "parts": [ + { "text": "Signal successfully activated on The Trade Desk" }, + { "data": { + "status": "completed", + "deployments": [{ + "type": "platform", + "platform": "the-trade-desk", + "account": "agency-123-ttd", + "is_live": true, + "activation_key": { + "type": "segment_id", + "segment_id": "ttd_agency123_lux_auto" + }, + "deployed_at": "2025-01-15T14:30:00Z" + }] + } } + ] + }] + } } ``` -Then via Server-Sent Events: -``` -data: {"message": "Validating signal access permissions..."} -data: {"message": "Configuring deployment on The Trade Desk..."} -data: {"message": "Finalizing activation..."} -data: {"status": {"state": "completed"}, "artifacts": [{ - "artifactId": "artifact-signal-activation-abc123", - "name": "signal_activation_result", - "parts": [ - {"kind": "text", "text": "Signal successfully activated on The Trade Desk"}, - {"kind": "data", "data": { - "context_id": "ctx-signals-123", - "deployments": [{ - "type": "platform", - "platform": "the-trade-desk", - "account": "agency-123-ttd", - "activation_key": { - "type": "segment_id", - "segment_id": "ttd_agency123_lux_auto" - }, - "deployed_at": "2025-01-15T14:30:00Z" - }] - }} - ] -}]} -``` - ### Protocol Transport - **MCP**: Returns task_id for polling-based asynchronous operation tracking or webhook-based push notifications -- **A2A**: Uses Server-Sent Events for real-time progress updates and completion +- **A2A**: Returns the typed result in a completed Task; clients may opt into the official streaming operations for transport progress - **Data Consistency**: Both protocols contain identical AdCP data structures and version information ### Webhook Support diff --git a/docs/signals/tasks/get_signals.mdx b/docs/signals/tasks/get_signals.mdx index a7931879af..85ebbb7e21 100644 --- a/docs/signals/tasks/get_signals.mdx +++ b/docs/signals/tasks/get_signals.mdx @@ -559,26 +559,21 @@ A buyer with credentials for both The Trade Desk and Amazon DSP receives keys fo ### A2A Request #### Natural Language Invocation -```javascript -await a2a.send({ - message: { - parts: [{ - kind: "text", - text: "Find me signals for high-income households interested in luxury goods that can be deployed on The Trade Desk and Amazon DSP in the US, with a maximum CPM of $5.00." - }] - } -}); -``` + +Text-only invocation requires a separate generic A2A interface whose Agent +Card does not mark the AdCP v3 profile as required. It is not an AdCP profile +invocation. #### Explicit Skill Invocation ```javascript await a2a.send({ message: { + messageId: crypto.randomUUID(), + role: "ROLE_USER", parts: [{ - kind: "data", data: { skill: "get_signals", - parameters: { + input: { signal_spec: "High-income households interested in luxury goods", destinations: [ { @@ -607,62 +602,43 @@ await a2a.send({ ``` ### A2A Response -A2A returns results as artifacts with the same data structure: + +A non-streaming `SendMessage` response selects the Task branch: + ```json { - "artifacts": [{ + "task": { + "id": "task-signal-discovery-001", + "contextId": "ctx-signals-123", + "status": { "state": "TASK_STATE_COMPLETED" }, + "artifacts": [{ "artifactId": "artifact-signal-discovery-def456", "name": "signal_discovery_result", "parts": [ - { - "kind": "text", - "text": "Found 1 luxury segment matching your criteria. Available on The Trade Desk, pending activation on Amazon DSP." - }, - { - "kind": "data", - "data": { - "context_id": "ctx-signals-123", - "signals": [ - { - "signal_ref": { - "scope": "data_provider", - "data_provider_domain": "experian.com", - "signal_id": "luxury_auto_intenders" - }, - "signal_agent_segment_id": "luxury_auto_intenders", - "name": "Luxury Automotive Intenders", - "description": "High-income individuals researching luxury vehicles", - "signal_type": "marketplace", - "data_provider": "Experian", - "coverage_percentage": 12, - "deployments": [ - { - "type": "agent", - "agent_url": "https://thetradedesk.com", - "account": "agency-123", - "is_live": true - }, - { - "type": "agent", - "agent_url": "https://advertising.amazon.com/dsp", - "is_live": false, - "estimated_activation_duration_minutes": 60 - } - ], - "pricing_options": [ - { - "pricing_option_id": "po_cpm_usd", - "model": "cpm", - "cpm": 3.50, - "currency": "USD" - } - ] - } - ] - } - } + { "text": "Found one matching luxury segment." }, + { "data": { + "status": "completed", + "cache_scope": "account", + "signals": [{ + "signal_ref": { + "scope": "data_provider", + "data_provider_domain": "provider.example", + "signal_id": "luxury_auto_intenders" + }, + "signal_agent_segment_id": "luxury_auto_intenders", + "name": "Luxury Automotive Intenders", + "description": "People researching luxury vehicles", + "signal_type": "marketplace", + "deployments": [{ + "type": "platform", + "platform": "the-trade-desk", + "is_live": false + }] + }] + } } ] }] + } } ``` diff --git a/package.json b/package.json index 08c44020be..28121cfd20 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "test:webhook-signing-vectors": "node --test --test-force-exit --test-timeout=30000 tests/webhook-signing-vectors.test.cjs", "test:webhook-receiver-envelope": "node --test --test-force-exit --test-timeout=30000 tests/webhook-receiver-envelope.test.cjs", "test:oauth-setup-vectors": "node --test --test-force-exit --test-timeout=30000 tests/oauth-setup-vectors.test.cjs", + "test:a2a-profile-extension": "node --test --test-force-exit --test-timeout=30000 tests/a2a-profile-extension-v3.test.cjs", "test:transport-errors": "node --test --test-force-exit --test-timeout=30000 tests/transport-error-mapping.test.cjs tests/request-signing-body-integrity.test.cjs", "test:targeting-overlay-vectors": "node --test --test-force-exit --test-timeout=30000 tests/media-buy-targeting-overlay-vectors.test.cjs", "test:targeting-aware-discovery": "node --test --test-force-exit --test-timeout=30000 tests/targeting-aware-discovery.test.cjs", @@ -131,7 +132,7 @@ "audit:oneof": "node scripts/audit-oneof.mjs", "test:schema-utf8": "node scripts/normalize-schema-utf8.mjs --check", "fix:schema-utf8": "node scripts/normalize-schema-utf8.mjs", - "test": "npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:language-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", + "test": "npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:a2a-profile-extension && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:language-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit:server-unit": "node scripts/precommit-server-unit.cjs", "precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 240 npm run precommit:server-unit && npm run typecheck", diff --git a/server/src/http.ts b/server/src/http.ts index d7394b675e..0fe7f19793 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -1408,6 +1408,13 @@ export class HTTPServer { }); }); + // Permanent A2A extension identifier. Keep the identifier on the AdCP + // origin while serving the maintained normative document from Mintlify. + this.app.get('/extensions/adcp/v3', (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.redirect(302, 'https://docs.adcontextprotocol.org/docs/building/by-layer/L0/a2a-profile-extension'); + }); + // Serve other static files (robots.txt, images, etc.) const staticPath = process.env.NODE_ENV === 'production' ? path.join(__dirname, "../static") diff --git a/skills/call-adcp-agent/SKILL.md b/skills/call-adcp-agent/SKILL.md index a863c2a83e..6819dd6311 100644 --- a/skills/call-adcp-agent/SKILL.md +++ b/skills/call-adcp-agent/SKILL.md @@ -15,14 +15,14 @@ AdCP (Ad Context Protocol) agents expose a fixed tool surface (`get_products`, ` - User wants to call a publisher / SSP / retail media network over AdCP - Tool names like `get_products`, `create_media_buy`, `sync_creatives`, `get_signals` appear in the available-tools list -- Agent card advertises `protocolVersion: '0.3.0'` with `skills` listing AdCP tool names +- A2A 1.0 Agent Card advertises `https://adcontextprotocol.org/extensions/adcp/v3` under `capabilities.extensions[]`, with `skills` listing AdCP task names - **Not this skill:** building an AdCP seller agent (see `@adcp/client/skills/build-seller-agent/` and analogous SDK skills) ## Discovery chain Walk these in order on first contact: -1. **Agent card** (A2A) or **`tools/list`** (MCP): returns tool NAMES. AdCP MCP servers no longer publish per-tool parameter schemas in `tools/list` — everything shows `{type: 'object', properties: {}}`. Don't try to infer shape from here. +1. **Agent card** (A2A) or **`tools/list`** (MCP): returns tool NAMES. For A2A 1.0, confirm the versioned AdCP profile under `capabilities.extensions[]` and activate it with `A2A-Extensions` on every call. Don't infer runtime AdCP capabilities from extension params. 2. **`get_adcp_capabilities`**: returns supported protocols (`media_buy`, `signals`, `creative`, …), AdCP major versions, feature flags. Tells you WHICH tools this agent supports, not how to call them. 3. **`get_schema(tool_name)`** *(when the agent exposes it — pending standardization in [#3057](https://github.com/adcontextprotocol/adcp/issues/3057), not yet universal)*: returns the JSON Schema for a tool's request/response. Preferred over reading bundled schemas when available. 4. **Bundled schemas** (offline, authoritative): every SDK ships the AdCP JSON Schemas locally. Path differs by SDK — spec repo source uses `dist/schemas//bundled/`, `@adcp/client` puts them at `schemas/cache//bundled/` after `npm run sync-schemas`, Python and Go SDKs use their own conventions. **Don't hardcode a path** — let the SDK's loader find them, or ask the developer. Each schema is `/-{request,response}.json` once you locate the bundle. The canonical source for every SDK is `https://adcontextprotocol.org/protocol/.tgz`. @@ -78,7 +78,7 @@ A mutating tool can return one of three shapes: { "errors": [{ "code": "PRODUCT_NOT_FOUND", "message": "..." }] } ``` -When you see `status: 'submitted'`, the work is NOT complete. Poll via `tasks/get` (A2A) or the MCP async task extension, using the `task_id`. Over A2A the AdCP `task_id` also rides on `artifact.metadata.adcp_task_id` — both work. +When you see `status: 'submitted'`, the work is NOT complete. Under the AdCP v3 A2A profile, the enclosing A2A Task is completed and the AdCP `task_id` appears only in the artifact DataPart. Poll by sending fresh profile invocations of `get_task_status` with that `task_id`; do not poll the completed A2A Task and do not look for `artifact.metadata.adcp_task_id`. On MCP, use the AdCP polling task the agent advertises. ### `packages[*]` on media buys @@ -246,7 +246,7 @@ Returns `{ signals: [{ signal_agent_segment_id, match_rate, pricing, ... }] }`. ## Transport notes - **MCP**: `tools/call` with `{ name: 'tool_name', arguments: {...} }`. Returns `{ content, structuredContent, isError? }`. Read `structuredContent` for the typed response. -- **A2A**: `message/send` with a `DataPart` of shape `{ skill: 'tool_name', input: {...} }` (the legacy key `parameters` is also accepted). Returns an A2A `Task`; the typed response is at `task.artifacts[0].parts[0].data`. +- **A2A 1.0**: activate `https://adcontextprotocol.org/extensions/adcp/v3`, then Send Message with exactly one invocation DataPart of shape `{ skill: 'tool_name', input: {...} }`. `parameters` is not an alias. Optional TextParts are advisory. Read the authoritative DataPart from the completed Task artifact. Both transports share: idempotency, error shape, schema enforcement, and handler semantics. If a call works on one, the equivalent call works on the other. @@ -256,7 +256,7 @@ Both transports share: idempotency, error shape, schema enforcement, and handler 2. **`budget` as an object**: it's a number. Currency comes from the `pricing_option`. 3. **`brand.brand_id` instead of `brand.domain`**: spec uses `domain`. 4. **Forgetting `idempotency_key`**: required on every mutating tool; see the list above. -5. **Treating A2A `Task.state: 'completed'` as AdCP completion**: A2A task state = transport call lifecycle. AdCP-level completion is in the artifact's payload (`structuredContent.status` or `data.status`). A `completed` A2A task can still carry a `submitted` AdCP response. +5. **Treating A2A `Task.state: 'completed'` as AdCP completion**: A2A task state = transport invocation lifecycle. AdCP-level completion is in the artifact DataPart. A completed A2A task can carry a submitted AdCP response; invoke `get_task_status` with its DataPart `task_id`. 6. **Using deprecated `format_id` in a new workflow**: AdCP 3.2 creatives use `format_kind` plus optional `format_option_ref`. Read these from the selected product's `format_options[]`; use `format_id` only when deliberately interoperating with an older 3.x peer. ## Symptom → fix @@ -273,7 +273,7 @@ Quick lookup before reading the full envelope. Match what you see in `adcp_error | `keyword: 'type'` or `additionalProperties` at `/budget` | Sent `{amount, currency}` | `budget` is a number. Currency is implied by `pricing_option_id`. | | `required` at `/format_kind` | Sent only a deprecated `format_id`, or omitted the canonical selector | Copy `format_kind` and, when needed, `format_option_ref` from the selected product's `format_options[]`. | | `keyword: 'enum'` at `/destinations/*/type` | Made-up destination type | Use `'platform'` (with `platform`) or `'agent'` (with `agent_url`). | -| Response carries `status: 'submitted'` and `task_id` | Async — work is queued, NOT done | Poll via `tasks/get` (A2A) or the MCP async task extension using `task_id`. | +| Response carries `status: 'submitted'` and `task_id` | Async — work is queued, NOT done | On the AdCP v3 A2A profile, invoke `get_task_status`; on MCP, use the advertised AdCP polling task. | | `recovery: 'transient'` (rate limit, 5xx, timeout) | Server-side, retry-safe | Retry with the **same** `idempotency_key`. | | `recovery: 'correctable'` | Buyer-side fix | Read `issues[]`, patch the pointers, resend. Most cases close in one attempt. | | `recovery: 'terminal'` (account suspended, payment required, …) | Requires human action | Don't retry. Surface to the user. | diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index 31e36a5ca4..77d4a0bfc6 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -431,7 +431,7 @@ phases: - current_step: what's happening ("Validating inventory", "Checking governance") Async with human approval (submitted): - - task_id / taskId: handle the buyer polls or receives webhooks on + - task_id: AdCP handle the buyer passes to get_task_status or receives webhooks on; it is not the A2A Task id - message (optional): explanation of what the seller is waiting on (e.g., "Awaiting IO signature from sales team; typical turnaround 2–4 hours") - No media_buy_id yet — it is issued on task completion. If you allocate media_buy_id before commitment, return synchronous success with confirmed_at: null instead, and make the provisional buy retrievable via get_media_buys. - Seller-side IO signing is modelled here (task stays submitted until signed). Do not emit a "pending_approval" media buy status — that value is not in MediaBuy.status @@ -494,7 +494,7 @@ phases: While the create_media_buy task is still submitted (e.g., waiting on internal IO signing), the media buy does not exist as a queryable MediaBuy yet. IO review is tracked at the task layer, not as a MediaBuy.status value. The buyer - polls tasks/get or waits on the webhook until the task completes and a + invokes get_task_status or waits on the webhook until the task completes and a media_buy_id is delivered. task: get_media_buys schema_ref: "media-buy/get-media-buys-request.json" diff --git a/static/compliance/source/protocols/media-buy/scenarios/create_media_buy_async.yaml b/static/compliance/source/protocols/media-buy/scenarios/create_media_buy_async.yaml index b7ddb5c23f..5b8173cd27 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/create_media_buy_async.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/create_media_buy_async.yaml @@ -22,17 +22,16 @@ narrative: | routing the request through IO signing, batch processing, or any out-of-band human workflow — the task layer carries the result, not the response. The seller emits the submitted task envelope: status='submitted', task_id present, no media_buy_id, no - packages. The buyer then polls tasks/get with task_id (or waits for a webhook) until - the task completes and the media_buy_id arrives on the completion artifact. + packages. The buyer then invokes get_task_status with task_id (or waits for a webhook) + until the task completes and the media_buy_id arrives in the terminal result. This scenario anchors the AdCP-payload-level invariant for that envelope. Three things matter and are easy to regress: 1. status MUST be the literal string 'submitted' (not 'pending', not a MediaBuyStatus value, not omitted) - 2. task_id MUST be present at the top of the payload, snake_case (A2A adapters MAY - surface it as taskId on the wire, but the payload field emitted by the agent is - task_id) + 2. task_id MUST be present at the top of the payload in snake_case on every transport; + it is distinct from an A2A transport Task id 3. media_buy_id and packages MUST NOT appear on the envelope — they land on the task's completion artifact, not here. Sellers that return media_buy_id with status='submitted' break the buyer's polling contract; buyers cannot tell whether the buy is queued or @@ -51,12 +50,12 @@ narrative: | storyboard catches sellers that fabricate a fresh task_id instead of honoring the registered directive. - Out of scope (by design). Transport-level wire-shape assertions — A2A Task.state and - artifact.metadata.adcp_task_id placement, MCP structuredContent envelope details — are - runner-side concerns, not storyboard assertions. The runner exercises this scenario - against both transports and probes the transport envelope independently. See - adcp-client#904 for the runner-side probes; this storyboard provides the deterministic - driver. + Out of scope (by design). Transport-level wire-shape assertions — including the A2A + completed-Task mapping and the prohibition on duplicating task_id in artifact metadata, + plus MCP structuredContent envelope details — are runner-side concerns, not storyboard + assertions. The runner exercises this scenario against both transports and probes the + transport envelope independently. See adcp-client#904 for the runner-side probes; this + storyboard provides the deterministic driver. The submitted → completed transition (forcing the task to resolve and asserting the completion artifact carries media_buy_id) is deferred to a follow-up scenario. It needs @@ -236,7 +235,7 @@ phases: description: "Status is the literal 'submitted' task-status value, not a MediaBuyStatus" - check: field_present path: "task_id" - description: "task_id is present at the top of the envelope (snake_case payload field, even when the A2A adapter surfaces it as taskId on the wire)" + description: "task_id is present at the top of the AdCP envelope in snake_case and remains distinct from any A2A transport Task id" - check: field_value path: "task_id" value: "$context.forced_task_id" diff --git a/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml b/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml index dfd7522b3b..18841e2fd6 100644 --- a/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml +++ b/static/compliance/source/specialisms/sales-broadcast-tv/index.yaml @@ -297,8 +297,9 @@ phases: and billing against C7 ratings. The response may be synchronous (buy confirmed) or — when traffic-manager - review is needed — the A2A task returns submitted with a task_id, and the - buyer waits on a webhook or tasks/get poll until the order is scheduled. + review is needed — the completed A2A invocation carries an AdCP Submitted + response with a task_id, and the buyer waits on a webhook or get_task_status + invocation until the order is scheduled. task: create_media_buy schema_ref: "media-buy/create-media-buy-request.json" response_schema_ref: "media-buy/create-media-buy-response.json" @@ -314,11 +315,14 @@ phases: - measurement_terms: confirmed guarantee window (c7) - valid_actions: sync_creatives as the next step - If traffic-manager review is needed, return an A2A task envelope instead: - - status: submitted (task-level — not a MediaBuy status) - - task_id / taskId: handle the buyer polls or receives webhooks on + If traffic-manager review is needed, return a completed A2A task envelope + whose artifact DataPart contains: + - status: submitted (AdCP application-level status, not a MediaBuy status) + - task_id: AdCP handle the buyer passes to get_task_status or receives webhooks on - message (optional): explanation that the traffic manager is reviewing + Do not duplicate task_id as the A2A Task id, taskId, or artifact metadata. + Do NOT use a "pending_approval" media buy status — that value is not in the MediaBuy.status enum. IO / traffic-manager review is modelled at the task layer. diff --git a/static/compliance/source/specialisms/sales-guaranteed/index.yaml b/static/compliance/source/specialisms/sales-guaranteed/index.yaml index c72941096f..1c393b6bd3 100644 --- a/static/compliance/source/specialisms/sales-guaranteed/index.yaml +++ b/static/compliance/source/specialisms/sales-guaranteed/index.yaml @@ -38,7 +38,7 @@ narrative: | A human reviewer on your side reviews the deal terms and signs the IO through your own internal workflow. - The buyer either polls tasks/get with the task_id or configures a push_notification_config + The buyer either invokes get_task_status with the task_id or configures a push_notification_config webhook to receive a callback when IO signing completes. Only on task completion does your platform issue a media_buy_id and the final CreateMediaBuy result; the buyer then calls get_media_buys to confirm the buy is active and sync creatives. @@ -384,9 +384,9 @@ phases: title: "Create guaranteed buy (task submitted for approval)" narrative: | The buyer creates a guaranteed media buy. Because your platform requires human - IO signing, the A2A task transitions to submitted rather than completed. The - buyer gets back a task_id and configures a webhook (or polls tasks/get) to be - notified when IO review finishes. + IO signing, the completed A2A invocation carries an AdCP Submitted response. The + buyer gets back an AdCP task_id and configures a webhook (or invokes + get_task_status) to be notified when IO review finishes. steps: - id: create_media_buy @@ -394,8 +394,9 @@ phases: narrative: | The buyer commits to guaranteed products with budgets and flight dates. Your platform accepts the request but does not create the media buy yet. Instead, - the A2A task enters the submitted state — no media_buy_id is issued because - IO signing may fail. The buyer receives a task_id to watch. + the A2A task completes with an AdCP status of submitted in its artifact + DataPart — no media_buy_id is issued because IO signing may fail. The buyer + receives an AdCP task_id to watch. The buyer includes push_notification_config so your platform can call back when the IO is signed (completed) or rejected (failed). @@ -406,15 +407,15 @@ phases: comply_scenario: create_media_buy stateful: true expected: | - Return an A2A task envelope in submitted state: - - status: submitted (task-level — the CreateMediaBuy success artifact is not yet produced) - - task_id / taskId: the handle the buyer polls or receives webhooks on + Return a completed A2A task envelope whose artifact DataPart contains: + - status: submitted (AdCP application-level status) + - task_id: the AdCP handle the buyer passes to get_task_status or receives webhooks on - message (optional): human-readable explanation (e.g., "Awaiting IO signature from sales team; typical turnaround 2–4 hours") Do NOT return media_buy_id or packages yet — those land on the task's final artifact - when the task transitions to completed. Do NOT return completed status for guaranteed - buys that require IO signing. Do NOT use a "pending_approval" media buy status; that - value is not in MediaBuy.status — IO review is modelled at the task layer only. + returned by a later get_task_status call. Do NOT duplicate task_id as A2A taskId or + artifact metadata. Do NOT use a "pending_approval" media buy status; that value is not + in MediaBuy.status — IO review is modelled at the AdCP task layer only. sample_request: brand: @@ -462,9 +463,9 @@ phases: title: "Confirm active after IO signing" narrative: | The human on your side reviews and signs the IO through your internal workflow. - Your platform then transitions the A2A task to completed and emits the final - CreateMediaBuy result — including the newly-issued media_buy_id — to the buyer's - push_notification webhook (or to the next tasks/get poll). The buyer now calls + Your platform then completes the AdCP task and emits the final CreateMediaBuy + result — including the newly-issued media_buy_id — to the buyer's push_notification + webhook (or returns it from the next get_task_status invocation). The buyer now calls get_media_buys with that media_buy_id and sees the buy active. There is no intermediate "pending_approval" media buy status in this flow; the buy does not exist as a queryable MediaBuy until the task completes. diff --git a/static/compliance/source/universal/storyboard-schema.yaml b/static/compliance/source/universal/storyboard-schema.yaml index 56956e0fb9..62b80293b6 100644 --- a/static/compliance/source/universal/storyboard-schema.yaml +++ b/static/compliance/source/universal/storyboard-schema.yaml @@ -1000,10 +1000,12 @@ # # in sample_request / context_inputs. # # Special path prefix `task_completion.`: when the immediate response -# is a non-terminal task envelope (status `submitted` / `working` / -# `input-required`, carrying a `task_id`), the runner polls `tasks/get` -# until the task reaches a terminal state and resolves `` against -# the completion artifact's `data` instead of the immediate response. Use +# carries a non-terminal AdCP response (status `submitted` / `working` / +# `input-required`, carrying a `task_id`), the runner polls the AdCP task +# until it reaches a terminal state and resolves `` against the +# completion result instead of the immediate response. On the AdCP v3 A2A profile, +# this means fresh `get_task_status` invocations; MCP may use its advertised +# AdCP polling task, including the legacy `tasks/get` alias. Use # for captures whose value only exists on the completion artifact — e.g. # the seller-assigned `media_buy_id` on an IO-signing / async-signed HITL # flow where `create_media_buy` returns `submitted` and the ID lands on @@ -1027,8 +1029,8 @@ # See runner-output-contract.yaml > validation_result for the output shape. # - The accumulator is storyboard-run-scoped; values do not leak across runs. # - Special path prefix `task_completion.`: when the immediate -# response is a non-terminal task envelope (status submitted/working, -# with a task_id), the runner polls `tasks/get` until the task exits +# response carries a non-terminal AdCP status (submitted/working, +# with a task_id), the runner polls the AdCP task until it exits # {submitted, working}. On `completed`: resolves against the # `result` payload; a missing path grades this step as # capture_path_not_resolvable. On any other status (failed, canceled, diff --git a/static/schemas/source/core/mcp-webhook-payload.json b/static/schemas/source/core/mcp-webhook-payload.json index 27aa21f262..361a434bcd 100644 --- a/static/schemas/source/core/mcp-webhook-payload.json +++ b/static/schemas/source/core/mcp-webhook-payload.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/mcp-webhook-payload.json", "title": "MCP Webhook Payload", - "description": "Standard envelope for HTTP-based push notifications (MCP). This defines the wire format sent to the URL configured in `pushNotificationConfig`. NOTE: This envelope is NOT used in A2A integration, which uses native Task/TaskStatusUpdateEvent messages with the AdCP payload nested in `status.message.parts[].data`.", + "description": "Standard envelope for AdCP application-layer HTTP push notifications. This defines the wire format sent to the URL configured in task input `push_notification_config`, including when that task was invoked through the AdCP A2A profile. It is distinct from A2A transport notifications registered through `configuration.taskPushNotificationConfig`, which use native StreamResponse branches.", "type": "object", "allOf": [ { diff --git a/static/schemas/source/core/protocol-envelope.json b/static/schemas/source/core/protocol-envelope.json index d967559073..4a9dd1f15a 100644 --- a/static/schemas/source/core/protocol-envelope.json +++ b/static/schemas/source/core/protocol-envelope.json @@ -176,7 +176,7 @@ "Task response schemas (e.g., get-products-response.json) define ONLY the body fields; protocol-layer fields live on this envelope.", "Transport serialization (normative):", " - MCP: envelope fields and task-body fields are siblings at the root of the tool response. The `payload` object is NOT serialized as a nested key — its body fields are flattened to the root alongside `status`, `context_id`, `context`, etc. This matches MCP's native `structuredContent` convention and is what shipping SDKs (@adcp/client) emit. Conformant MCP receivers parse from the flat root; receivers that expect a nested `payload` key MUST migrate.", - " - A2A (0.3.0+): `task.status.state` carries the transport lifecycle used to select the extraction location; `task.contextId` carries `context_id`, and `task.id` carries `task_id`. The complete AdCP response, including its protocol-level `status`, is canonically carried in `task.artifacts[0].parts[].DataPart` on final states; `task.status.message.parts[].DataPart` is the fallback container used only for interim states (working, input-required) where no final artifact has been emitted yet. The two statuses normally align. For a structured AdCP business rejection, however, A2A remains `completed` while the DataPart carries `status: rejected` because the transport call succeeded. Receivers MUST prefer artifacts when present and MUST NOT overwrite a DataPart's status with the A2A state. See `a2a-response-extraction.mdx` for the full canonical/fallback algorithm.", + " - A2A 1.0 with the AdCP profile extension: `task.status.state` carries the transport invocation lifecycle used to select the extraction location; `task.contextId` carries `context_id`, while `task.id` is an independent A2A transport identifier. The complete AdCP response, including its protocol-level `status` and any application-layer `task_id`, is canonically carried in `task.artifacts[0].parts[].DataPart` on final states; `task.status.message.parts[].DataPart` is the fallback container used only for interim A2A states (working, native submitted, input-required) where no final artifact has been emitted yet. For a structured AdCP business rejection or Submitted response, A2A is `completed` while the DataPart carries `status: rejected` or `status: submitted` because the handler invocation succeeded. An AdCP `task_id` MUST NOT be copied into artifact metadata or inferred from `task.id`. Receivers MUST prefer artifacts when present and MUST NOT overwrite a DataPart's status with the A2A state. See `a2a-profile-extension.mdx` and `a2a-response-extraction.mdx` for the full binding and extraction algorithm.", " - REST: envelope fields MAY ride on HTTP headers (e.g., `X-AdCP-Status`, `X-AdCP-Context-Id`) or as JSON body siblings; body fields appear at the JSON body root. Implementers choosing the header path SHOULD also mirror to body siblings for non-streaming callers.", "Across all three: envelope and body fields are conceptually a single response object. A task response schema MAY declare body fields with the same name as envelope fields (e.g., `errors[]` body-level for per-record validation results vs envelope-level for fatal task failure) and the two MUST be treated as distinct fields by name within their respective namespaces — see `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`.", "`status` is REQUIRED on the conceptual AdCP response across all transports. On MCP and REST it appears as a sibling field at the JSON root (or `structuredContent` root for MCP). On A2A it is carried inside the authoritative DataPart alongside the task body; `task.status.state` separately carries the A2A transport lifecycle and selects where that DataPart is found. Receivers MUST preserve the DataPart status when present. The schema-level `required: [status]` enforces the post-extraction AdCP shape. `payload` remains intentionally NOT required — it is a documentary grouping construct, never a required wire field. See `mcp-guide.mdx` and `a2a-guide.mdx` for the wire-level patterns receivers MUST implement.", diff --git a/static/schemas/source/creative/sync-creatives-async-response-submitted.json b/static/schemas/source/creative/sync-creatives-async-response-submitted.json index c574caabef..e82fafe8e0 100644 --- a/static/schemas/source/creative/sync-creatives-async-response-submitted.json +++ b/static/schemas/source/creative/sync-creatives-async-response-submitted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/sync-creatives-async-response-submitted.json", "title": "Sync Creatives - Submitted", - "description": "Async task envelope returned when the whole sync_creatives operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion or when async review must settle before per-item results can be issued. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the creatives array lands on the completion artifact, not this envelope.", + "description": "Async task envelope returned when the whole sync_creatives operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion or when async review must settle before per-item results can be issued. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the creatives array lands in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The creatives array is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. The creatives array is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/creative/sync-creatives-response.json b/static/schemas/source/creative/sync-creatives-response.json index d312129026..3c21fcc29e 100644 --- a/static/schemas/source/creative/sync-creatives-response.json +++ b/static/schemas/source/creative/sync-creatives-response.json @@ -275,7 +275,7 @@ }, { "title": "SyncCreativesSubmitted", - "description": "Async task envelope returned when the whole sync operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion, when async review must settle before per-item results can be issued, or when governance review gates the sync. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the creatives array with per-item action/status lands on the completion artifact, not this envelope. Per-item async review (an item in pending_review while the rest of the sync resolves synchronously) belongs on the SyncCreativesSuccess branch with status: pending_review, not here.", + "description": "Async task envelope returned when the whole sync operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion, when async review must settle before per-item results can be issued, or when governance review gates the sync. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the creatives array with per-item action/status lands in the terminal result, not this envelope. Per-item async review (an item in pending_review while the rest of the sync resolves synchronously) belongs on the SyncCreativesSuccess branch with status: pending_review, not here.", "type": "object", "properties": { "status": { @@ -285,7 +285,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The creatives array is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. The creatives array is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/build-creative-async-response-submitted.json b/static/schemas/source/media-buy/build-creative-async-response-submitted.json index f6c92c1adf..c445cb81cf 100644 --- a/static/schemas/source/media-buy/build-creative-async-response-submitted.json +++ b/static/schemas/source/media-buy/build-creative-async-response-submitted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/build-creative-async-response-submitted.json", "title": "Build Creative - Submitted", - "description": "Async task envelope returned when build_creative cannot be confirmed before the response — for example, when a slow generative pipeline or multi-minute LLM workflow is queued. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the creative_manifest(s) land on the completion artifact, not this envelope.", + "description": "Async task envelope returned when build_creative cannot be confirmed before the response — for example, when a slow generative pipeline or multi-minute LLM workflow is queued. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the creative_manifest(s) land in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index e9333d8673..b36bed67ed 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -652,7 +652,7 @@ }, { "title": "BuildCreativeSubmitted", - "description": "Async task envelope returned when build_creative cannot be confirmed before the response — for example, when a slow generative pipeline or multi-minute LLM workflow is queued. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the creative_manifest(s) land on the completion artifact, not this envelope.", + "description": "Async task envelope returned when build_creative cannot be confirmed before the response — for example, when a slow generative pipeline or multi-minute LLM workflow is queued. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the creative_manifest(s) land in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -662,7 +662,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json b/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json index 67dbc0904e..960be5ffca 100644 --- a/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json +++ b/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/create-media-buy-async-response-submitted.json", "title": "Create Media Buy - Submitted", - "description": "Async task envelope returned when create_media_buy cannot be confirmed before the response is emitted. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the media_buy_id and packages land on the completion artifact, not this envelope.", + "description": "Async task envelope returned when create_media_buy cannot be confirmed before the response is emitted. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the media_buy_id and packages land in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The media_buy_id is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. The media_buy_id is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/create-media-buy-response.json b/static/schemas/source/media-buy/create-media-buy-response.json index 96a8685eaa..234540d0ad 100644 --- a/static/schemas/source/media-buy/create-media-buy-response.json +++ b/static/schemas/source/media-buy/create-media-buy-response.json @@ -253,7 +253,7 @@ }, { "title": "CreateMediaBuySubmitted", - "description": "Async task envelope returned when the media buy cannot be confirmed before the response is emitted — for example, when a guaranteed buy requires IO signing, when governance review is outstanding, or when the seller has queued the request for batch processing. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the media_buy_id and packages land on the completion artifact, not this envelope. Do not use a 'pending_approval' MediaBuy.status for this case — that value is not in MediaBuyStatus; IO review and similar pre-issuance workflows are modeled at the task layer only.", + "description": "Async task envelope returned when the media buy cannot be confirmed before the response is emitted — for example, when a guaranteed buy requires IO signing, when governance review is outstanding, or when the seller has queued the request for batch processing. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the media_buy_id and packages land in the terminal result, not this envelope. Do not use a 'pending_approval' MediaBuy.status for this case — that value is not in MediaBuyStatus; IO review and similar pre-issuance workflows are modeled at the task layer only.", "type": "object", "properties": { "status": { @@ -263,7 +263,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The media_buy_id is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. The media_buy_id is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/get-products-async-response-submitted.json b/static/schemas/source/media-buy/get-products-async-response-submitted.json index db4a63fa39..088542bb2c 100644 --- a/static/schemas/source/media-buy/get-products-async-response-submitted.json +++ b/static/schemas/source/media-buy/get-products-async-response-submitted.json @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The products array is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. The products array is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/sync-audiences-response.json b/static/schemas/source/media-buy/sync-audiences-response.json index 5bffc45e17..896e802c1d 100644 --- a/static/schemas/source/media-buy/sync-audiences-response.json +++ b/static/schemas/source/media-buy/sync-audiences-response.json @@ -231,7 +231,7 @@ }, { "title": "SyncAudiencesSubmitted", - "description": "Async task envelope returned when the whole sync operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion, when governance review gates the upload before matching can start, or when an upstream clean-room flow needs to settle before any per-audience result can be issued. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the audiences array with per-item action/status lands on the completion artifact, not this envelope. Per-audience asynchronous matching (one audience in 'processing' while the rest of the sync resolves synchronously) belongs on the SyncAudiencesSuccess branch with status: processing on that item, not here. Matching latency on the per-audience status enum (processing → ready / too_small) is the common case; this envelope is the less-common operation-level case.", + "description": "Async task envelope returned when the whole sync operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion, when governance review gates the upload before matching can start, or when an upstream clean-room flow needs to settle before any per-audience result can be issued. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the audiences array with per-item action/status lands in the terminal result, not this envelope. Per-audience asynchronous matching (one audience in 'processing' while the rest of the sync resolves synchronously) belongs on the SyncAudiencesSuccess branch with status: processing on that item, not here. Matching latency on the per-audience status enum (processing → ready / too_small) is the common case; this envelope is the less-common operation-level case.", "type": "object", "properties": { "status": { @@ -241,7 +241,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. The audiences array is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. The audiences array is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json b/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json index acaf055e92..2aec666604 100644 --- a/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json +++ b/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/sync-catalogs-async-response-submitted.json", "title": "Sync Catalogs - Submitted", - "description": "Async task envelope returned when sync_catalogs cannot be confirmed before the response — for example, when catalog ingestion and deduplication are queued for batch processing. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the per-catalog results land on the completion artifact, not this envelope.", + "description": "Async task envelope returned when sync_catalogs cannot be confirmed before the response — for example, when catalog ingestion and deduplication are queued for batch processing. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the per-catalog results land in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/sync-catalogs-response.json b/static/schemas/source/media-buy/sync-catalogs-response.json index 1e85e6c71a..d9e015a171 100644 --- a/static/schemas/source/media-buy/sync-catalogs-response.json +++ b/static/schemas/source/media-buy/sync-catalogs-response.json @@ -315,7 +315,7 @@ }, { "title": "SyncCatalogsSubmitted", - "description": "Async task envelope returned when a catalog-only sync cannot be confirmed before the response — for example, when catalog ingestion and deduplication are queued for batch processing. A request containing item_availability_updates or item_availability_queries MUST NOT return this branch because availability operations are synchronous; mixed requests whose catalog work cannot finish synchronously and atomically fail before mutation and are retried as separate calls. For catalog-only requests, the buyer polls tasks/get with task_id or receives a webhook when the task completes; the per-catalog results land on the completion artifact, not this envelope.", + "description": "Async task envelope returned when a catalog-only sync cannot be confirmed before the response — for example, when catalog ingestion and deduplication are queued for batch processing. A request containing item_availability_updates or item_availability_queries MUST NOT return this branch because availability operations are synchronous; mixed requests whose catalog work cannot finish synchronously and atomically fail before mutation and are retried as separate calls. For catalog-only requests, the buyer invokes get_task_status with task_id or receives a webhook when the task completes; the per-catalog results land in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -325,7 +325,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json b/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json index 8bfdfdb34d..64f8759b9e 100644 --- a/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json +++ b/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/update-media-buy-async-response-submitted.json", "title": "Update Media Buy - Submitted", - "description": "Async task envelope returned when update_media_buy cannot be confirmed before the response — for example, when operator re-approval is required for mid-flight changes. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the updated media buy state lands on the completion artifact, not this envelope.", + "description": "Async task envelope returned when update_media_buy cannot be confirmed before the response — for example, when operator re-approval is required for mid-flight changes. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the updated media buy state lands in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/media-buy/update-media-buy-response.json b/static/schemas/source/media-buy/update-media-buy-response.json index e295cabd36..d851378f87 100644 --- a/static/schemas/source/media-buy/update-media-buy-response.json +++ b/static/schemas/source/media-buy/update-media-buy-response.json @@ -204,7 +204,7 @@ }, { "title": "UpdateMediaBuySubmitted", - "description": "Async task envelope returned when update_media_buy cannot be confirmed before the response — for example, when operator re-approval is required for mid-flight changes. The buyer polls tasks/get with task_id or receives a webhook when the task completes; the updated media buy state lands on the completion artifact, not this envelope.", + "description": "Async task envelope returned when update_media_buy cannot be confirmed before the response — for example, when operator re-approval is required for mid-flight changes. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the updated media buy state lands in the terminal result, not this envelope.", "type": "object", "properties": { "status": { @@ -214,7 +214,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the buyer uses with tasks/get, and that the seller references on push-notification callbacks. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the buyer uses with get_task_status (or the legacy AdCP tasks/get alias), and that the seller references on push-notification callbacks. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/schemas/source/signals/get-signals-async-response-submitted.json b/static/schemas/source/signals/get-signals-async-response-submitted.json index 331486bdc8..98252f974c 100644 --- a/static/schemas/source/signals/get-signals-async-response-submitted.json +++ b/static/schemas/source/signals/get-signals-async-response-submitted.json @@ -12,7 +12,7 @@ }, "task_id": { "type": "string", - "description": "Task handle the caller uses with tasks/get, and that the agent references on push-notification callbacks. The signals array is issued on the completion artifact, not here. Per AdCP wire conventions this is snake_case; A2A adapters MAY surface it as taskId, but the payload field emitted by the agent is task_id.", + "description": "Task handle the caller uses with get_task_status (or the legacy AdCP tasks/get alias), and that the agent references on push-notification callbacks. The signals array is issued on the completion artifact, not here. This AdCP application-layer handle remains the snake_case task_id in every transport payload and is distinct from any transport-native A2A Task id.", "x-entity": "task" }, "message": { diff --git a/static/test-vectors/a2a-profile-extension-v3.json b/static/test-vectors/a2a-profile-extension-v3.json new file mode 100644 index 0000000000..ffb886a9a9 --- /dev/null +++ b/static/test-vectors/a2a-profile-extension-v3.json @@ -0,0 +1,362 @@ +{ + "version": "3.0.0", + "extension_uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "a2a_protocol_version": "1.0", + "description": "Reference vectors for the AdCP A2A Profile Extension v3 advertisement, activation, invocation, response, and AdCP polling rules.", + "advertisement_vectors": [ + { + "id": "agent-card-capabilities-extension", + "description": "The versioned profile is required and advertised under AgentCard.capabilities.extensions without capability params.", + "valid": true, + "agent_card": { + "name": "StreamHaus sales agent", + "description": "Advertising sales agent", + "version": "3.2.0", + "supportedInterfaces": [{ + "url": "https://sales.streamhaus.example/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }], + "capabilities": { + "extensions": [{ + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "description": "AdCP structured task invocation profile", + "required": true + }] + }, + "defaultInputModes": ["application/json"], + "defaultOutputModes": ["application/json"], + "skills": [ + {"id": "get_adcp_capabilities", "name": "Discover AdCP capabilities", "description": "Discover runtime AdCP capabilities", "tags": ["adcp"]}, + {"id": "get_task_status", "name": "Get AdCP task status", "description": "Poll an AdCP application task", "tags": ["adcp"]} + ] + } + }, + { + "id": "agent-card-top-level-extension-invalid", + "description": "A top-level extensions array does not advertise an A2A 1.0 AgentExtension.", + "valid": false, + "expected_error": "extension_not_under_capabilities", + "agent_card": { + "capabilities": {}, + "extensions": [{ + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "required": true + }] + } + }, + { + "id": "agent-card-capability-params-invalid", + "description": "Runtime AdCP capabilities must not be duplicated in AgentExtension.params.", + "valid": false, + "expected_error": "extension_params_not_empty", + "agent_card": { + "capabilities": { + "extensions": [{ + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "required": true, + "params": { + "adcp_version": "3.2", + "protocols_supported": ["media_buy"] + } + }] + } + } + }, + { + "id": "agent-card-skill-name-not-id-invalid", + "description": "The canonical task name belongs in AgentSkill.id; the human-readable name is not a dispatch key.", + "valid": false, + "expected_error": "missing_get_adcp_capabilities_skill_id", + "agent_card": { + "capabilities": { + "extensions": [{ + "uri": "https://adcontextprotocol.org/extensions/adcp/v3", + "required": true + }] + }, + "skills": [{ + "id": "capability-discovery", + "name": "get_adcp_capabilities", + "description": "Incorrectly places the task dispatch name in AgentSkill.name", + "tags": ["adcp"] + }] + } + } + ], + "invocation_vectors": [ + { + "id": "activated-structured-invocation", + "description": "An activated request carries one authoritative DataPart with skill and input.", + "valid": true, + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-invoke-001", + "role": "ROLE_USER", + "parts": [{ + "data": { + "skill": "get_products", + "input": { + "buying_mode": "brief", + "brief": "Premium CTV inventory for a spring campaign" + } + } + }] + } + }, + { + "id": "activated-invocation-with-advisory-text", + "description": "A generated display-only TextPart may accompany the one authoritative invocation DataPart.", + "valid": true, + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://example.com/trace/v1, https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-invoke-002", + "role": "ROLE_USER", + "parts": [ + {"text": "AdCP task: get_products", "mediaType": "text/plain", "metadata": {"generated": true}}, + {"data": {"skill": "get_products", "input": {"buying_mode": "brief", "brief": "Premium CTV inventory"}}} + ] + } + }, + { + "id": "profile-not-activated-invalid", + "description": "The structured profile is inactive without A2A-Extensions activation.", + "valid": false, + "expected_error": "extension_not_activated", + "headers": {"A2A-Version": "1.0"}, + "message": { + "messageId": "msg-invoke-003", + "role": "ROLE_USER", + "parts": [{"data": {"skill": "get_products", "input": {}}}] + } + }, + { + "id": "missing-message-id-invalid", + "description": "A2A 1.0 requires messageId on every Message, including profile invocations.", + "valid": false, + "expected_error": "invalid_a2a_message", + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "role": "ROLE_USER", + "parts": [{"data": {"skill": "get_products", "input": {"buying_mode": "brief", "brief": "Premium CTV inventory"}}}] + } + }, + { + "id": "legacy-parameters-invalid", + "description": "parameters is not an alias for input in the AdCP v3 A2A profile.", + "valid": false, + "expected_error": "invalid_invocation_shape", + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-invoke-004", + "role": "ROLE_USER", + "parts": [{"data": {"skill": "get_products", "parameters": {}}}] + } + }, + { + "id": "duplicate-invocation-datapart-invalid", + "description": "Two invocation DataParts are ambiguous and must be rejected.", + "valid": false, + "expected_error": "multiple_invocation_dataparts", + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-invoke-005", + "role": "ROLE_USER", + "parts": [ + {"data": {"skill": "get_products", "input": {}}}, + {"data": {"skill": "create_media_buy", "input": {}}} + ] + } + }, + { + "id": "file-part-invalid", + "description": "FileParts are outside the AdCP v3 A2A invocation profile.", + "valid": false, + "expected_error": "unsupported_part_type", + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-invoke-006", + "role": "ROLE_USER", + "parts": [ + {"data": {"skill": "sync_creatives", "input": {}}}, + {"url": "https://cdn.example.com/creative.mp4", "mediaType": "video/mp4"} + ] + } + }, + { + "id": "get-task-status-poll", + "description": "A durable AdCP task is polled through a fresh activated get_task_status invocation.", + "valid": true, + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-poll-001", + "role": "ROLE_USER", + "parts": [{ + "data": { + "skill": "get_task_status", + "input": { + "task_id": "adcp-task-9a21", + "include_result": true + } + } + }] + } + }, + { + "id": "input-required-continuation", + "description": "A continuation uses a new messageId inside a Message that retains the A2A taskId and contextId and resends typed input.", + "valid": true, + "headers": { + "A2A-Version": "1.0", + "A2A-Extensions": "https://adcontextprotocol.org/extensions/adcp/v3" + }, + "message": { + "messageId": "msg-continue-001", + "taskId": "a2a-task-input-001", + "contextId": "ctx-input-001", + "role": "ROLE_USER", + "parts": [{ + "data": { + "skill": "get_products", + "input": { + "buying_mode": "brief", + "brief": "Premium CTV inventory with a $75K budget" + } + } + }] + } + } + ], + "response_vectors": [ + { + "id": "bare-send-message-task-invalid", + "description": "A non-streaming SendMessage response must select the response task branch rather than return a bare Task.", + "valid": false, + "handler_return": "completed", + "expected_error": "invalid_send_message_response", + "response": { + "id": "a2a-task-bare-001", + "contextId": "ctx-bare-001", + "status": {"state": "TASK_STATE_COMPLETED"}, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{"data": {"status": "completed", "products": []}}] + }] + } + }, + { + "id": "submitted-inside-completed-a2a-task", + "description": "A queued AdCP operation is returned in a completed A2A Task with its handle only in the DataPart.", + "valid": true, + "handler_return": "submitted", + "response": { + "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"}} + ] + }] + } + }, + "expected_adcp_task_id": "adcp-task-9a21" + }, + { + "id": "submitted-native-a2a-state-invalid", + "description": "An AdCP Submitted handler result must not leave the A2A Task in its native submitted state.", + "valid": false, + "handler_return": "submitted", + "expected_error": "submitted_handler_return_not_a2a_completed", + "response": { + "task": { + "id": "a2a-task-create-43", + "contextId": "ctx-create-43", + "status": {"state": "TASK_STATE_SUBMITTED"}, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{"data": {"status": "submitted", "task_id": "adcp-task-9a22"}}] + }] + } + } + }, + { + "id": "duplicated-adcp-task-id-invalid", + "description": "artifact.metadata.adcp_task_id duplicates the authoritative DataPart handle and is forbidden.", + "valid": false, + "handler_return": "submitted", + "expected_error": "adcp_task_id_metadata_duplication", + "response": { + "task": { + "id": "a2a-task-create-44", + "contextId": "ctx-create-44", + "status": {"state": "TASK_STATE_COMPLETED"}, + "artifacts": [{ + "artifactId": "adcp-result", + "metadata": {"adcp_task_id": "adcp-task-9a23"}, + "parts": [{"data": {"status": "submitted", "task_id": "adcp-task-9a23"}}] + }] + } + } + }, + { + "id": "completed-get-task-status-result", + "description": "Each get_task_status poll is a completed A2A invocation carrying a direct AdCP polling response.", + "valid": true, + "handler_return": "get_task_status", + "response": { + "task": { + "id": "a2a-task-poll-11", + "contextId": "ctx-poll-11", + "status": {"state": "TASK_STATE_COMPLETED"}, + "artifacts": [{ + "artifactId": "adcp-result", + "parts": [{ + "data": { + "task_id": "adcp-task-9a21", + "task_type": "create_media_buy", + "protocol": "media-buy", + "status": "completed", + "created_at": "2026-08-17T09:00:00Z", + "updated_at": "2026-08-17T09:05:00Z", + "completed_at": "2026-08-17T09:05:00Z", + "result": { + "status": "completed", + "media_buy_id": "mb_12345", + "confirmed_at": "2026-08-17T09:05:00Z", + "revision": 1, + "packages": [{"package_id": "pkg_001"}] + } + } + }] + }] + } + }, + "expected_adcp_task_id": "adcp-task-9a21" + } + ] +} diff --git a/tests/a2a-profile-extension-v3.test.cjs b/tests/a2a-profile-extension-v3.test.cjs new file mode 100644 index 0000000000..d712af4ee0 --- /dev/null +++ b/tests/a2a-profile-extension-v3.test.cjs @@ -0,0 +1,215 @@ +const fs = require('fs'); +const path = require('path'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const vectorsPath = path.join(__dirname, '..', 'static', 'test-vectors', 'a2a-profile-extension-v3.json'); +const vectors = JSON.parse(fs.readFileSync(vectorsPath, 'utf8')); +const URI = 'https://adcontextprotocol.org/extensions/adcp/v3'; +const schemaRoot = path.join(__dirname, '..', 'static', 'schemas', 'source'); +const ajv = new Ajv({ + allErrors: true, + strict: false, + discriminator: true, + loadSchema: async (uri) => { + if (!uri.startsWith('/schemas/')) throw new Error(`Cannot load external schema: ${uri}`); + return JSON.parse(fs.readFileSync(path.join(schemaRoot, uri.replace('/schemas/', '')), 'utf8')); + }, +}); +addFormats(ajv); + +async function compile(schemaId) { + const existing = ajv.getSchema(schemaId); + if (existing) return existing; + const schema = JSON.parse(fs.readFileSync(path.join(schemaRoot, schemaId.replace('/schemas/', '')), 'utf8')); + return ajv.compileAsync(schema); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function validateAdvertisement(agentCard) { + const topLevel = agentCard?.extensions; + const extensions = agentCard?.capabilities?.extensions; + if (!Array.isArray(extensions)) { + if (Array.isArray(topLevel) && topLevel.some(ext => ext?.uri === URI)) { + return 'extension_not_under_capabilities'; + } + return 'extension_not_advertised'; + } + const extension = extensions.find(ext => ext?.uri === URI); + if (!extension) return 'extension_not_advertised'; + if (extension.required !== true) return 'extension_not_required'; + if (extension.params !== undefined && (!isObject(extension.params) || Object.keys(extension.params).length > 0)) { + return 'extension_params_not_empty'; + } + const skillIds = new Set((agentCard.skills ?? []).map(skill => skill?.id)); + if (!skillIds.has('get_adcp_capabilities')) return 'missing_get_adcp_capabilities_skill_id'; + return null; +} + +function activated(headers) { + if (headers?.['A2A-Version'] !== '1.0') return false; + const activatedExtensions = new Set(String(headers?.['A2A-Extensions'] ?? '') + .split(',') + .map(value => value.trim())); + return activatedExtensions.has(URI); +} + +function validateInvocation(vector) { + if (!activated(vector.headers)) return 'extension_not_activated'; + if (typeof vector.message?.messageId !== 'string' || vector.message.messageId.length === 0) { + return 'invalid_a2a_message'; + } + if (vector.message.role !== 'ROLE_USER') return 'invalid_a2a_message'; + const parts = vector.message?.parts; + if (!Array.isArray(parts) || parts.length === 0) return 'invalid_a2a_message'; + + for (const part of parts) { + if (!isObject(part)) return 'invalid_a2a_message'; + const contentFields = ['text', 'raw', 'url', 'data'].filter(field => Object.hasOwn(part, field)); + if (contentFields.length !== 1) return 'invalid_a2a_message'; + } + + const dataParts = parts.filter(part => isObject(part) && Object.hasOwn(part, 'data')); + if (dataParts.length > 1) return 'multiple_invocation_dataparts'; + if (dataParts.length !== 1) return 'invalid_invocation_shape'; + + for (const part of parts) { + if (part === dataParts[0]) continue; + const allowedTextFields = new Set(['text', 'metadata', 'filename', 'mediaType']); + if (!isObject(part) || typeof part.text !== 'string' + || Object.keys(part).some(key => !allowedTextFields.has(key))) { + return 'unsupported_part_type'; + } + } + + const invocation = dataParts[0].data; + if (!isObject(invocation)) return 'invalid_invocation_shape'; + const keys = Object.keys(invocation).sort(); + if (keys.length !== 2 || keys[0] !== 'input' || keys[1] !== 'skill') return 'invalid_invocation_shape'; + if (typeof invocation.skill !== 'string' || invocation.skill.length === 0 || !isObject(invocation.input)) { + return 'invalid_invocation_shape'; + } + return null; +} + +function lastDataPart(task) { + const parts = task?.artifacts?.[0]?.parts; + if (!Array.isArray(parts)) return null; + const dataParts = parts.filter(part => isObject(part?.data)); + return dataParts.at(-1) ?? null; +} + +function validateResponse(vector) { + const response = vector.response; + if (!isObject(response) || Object.keys(response).length !== 1 || !isObject(response.task)) { + return 'invalid_send_message_response'; + } + const task = response.task; + if (typeof task.id !== 'string' || task.id.length === 0 || !isObject(task.status)) { + return 'invalid_a2a_task'; + } + if (!Array.isArray(task.artifacts) || task.artifacts.some(artifact => + typeof artifact?.artifactId !== 'string' || !Array.isArray(artifact?.parts))) { + return 'invalid_a2a_task'; + } + const data = lastDataPart(task)?.data; + if (!data) return 'missing_adcp_datapart'; + + if (task.artifacts.some(artifact => Object.hasOwn(artifact?.metadata ?? {}, 'adcp_task_id'))) { + return 'adcp_task_id_metadata_duplication'; + } + + if (vector.handler_return === 'submitted') { + if (task.status?.state !== 'TASK_STATE_COMPLETED') { + return 'submitted_handler_return_not_a2a_completed'; + } + if (data.status !== 'submitted' || typeof data.task_id !== 'string') { + return 'invalid_submitted_datapart'; + } + } + + if (vector.expected_adcp_task_id !== undefined && data.task_id !== vector.expected_adcp_task_id) { + return 'wrong_adcp_task_id'; + } + return null; +} + +describe('AdCP A2A Profile Extension v3 vectors', () => { + it('pins the versioned extension identity', () => { + assert.equal(vectors.version, '3.0.0'); + assert.equal(vectors.extension_uri, URI); + assert.equal(vectors.a2a_protocol_version, '1.0'); + }); + + for (const vector of vectors.advertisement_vectors) { + it(`validates advertisement: ${vector.id}`, () => { + const error = validateAdvertisement(vector.agent_card); + assert.equal(error, vector.valid ? null : vector.expected_error); + }); + } + + for (const vector of vectors.invocation_vectors) { + it(`validates invocation: ${vector.id}`, async () => { + const error = validateInvocation(vector); + assert.equal(error, vector.valid ? null : vector.expected_error); + if (vector.valid) { + const invocation = vector.message.parts.find(part => isObject(part.data)).data; + const schemaBySkill = { + get_products: '/schemas/media-buy/get-products-request.json', + get_task_status: '/schemas/protocol/get-task-status-request.json', + }; + if (schemaBySkill[invocation.skill]) { + const validate = await compile(schemaBySkill[invocation.skill]); + assert.equal(validate(invocation.input), true, JSON.stringify(validate.errors)); + } + } + }); + } + + for (const vector of vectors.response_vectors) { + it(`validates response: ${vector.id}`, async () => { + const error = validateResponse(vector); + assert.equal(error, vector.valid ? null : vector.expected_error); + if (vector.valid && vector.handler_return === 'get_task_status') { + const validate = await compile('/schemas/protocol/get-task-status-response.json'); + const data = lastDataPart(vector.response.task).data; + assert.equal(validate(data), true, JSON.stringify(validate.errors)); + const terminalSchemaByTaskType = { + create_media_buy: '/schemas/media-buy/create-media-buy-response.json', + }; + const resultSchema = terminalSchemaByTaskType[data.task_type]; + assert.ok(resultSchema, `no terminal schema mapping for ${data.task_type}`); + const validateResult = await compile(resultSchema); + assert.equal(validateResult(data.result), true, JSON.stringify(validateResult.errors)); + } + }); + } + + it('covers advertisement, activation, advisory text, submitted mapping, metadata prohibition, and polling', () => { + const ids = new Set([ + ...vectors.advertisement_vectors, + ...vectors.invocation_vectors, + ...vectors.response_vectors, + ].map(vector => vector.id)); + for (const required of [ + 'agent-card-capabilities-extension', + 'agent-card-skill-name-not-id-invalid', + 'profile-not-activated-invalid', + 'missing-message-id-invalid', + 'activated-invocation-with-advisory-text', + 'input-required-continuation', + 'bare-send-message-task-invalid', + 'submitted-inside-completed-a2a-task', + 'duplicated-adcp-task-id-invalid', + 'get-task-status-poll', + 'completed-get-task-status-result', + ]) { + assert.ok(ids.has(required), `missing required vector ${required}`); + } + }); +});