Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/refactor-push-notification-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"adcontextprotocol": patch
---

Redesign how AdCP handles push notifications for async tasks. The key change is separating **what data is sent** (AdCP's responsibility) from **how it's delivered** (protocol's responsibility).

**Renamed:**

- `webhook-payload.json` → `mcp-webhook-payload.json` (clarifies this envelope is MCP-specific)

**Created:**

- `async-response-data.json` - Union schema for all async response data types
- Status-specific schemas for `working`, `input-required`, and `submitted` statuses

**Deleted:**

- Removed redundant `-async-response-completed.json` and `-async-response-failed.json` files (6 total)
- For `completed`/`failed`, we now use the existing task response schemas directly

**Before:** The webhook spec tried to be universal, which created confusion about how A2A's native push notifications fit in.

**After:**

- MCP uses `mcp-webhook-payload.json` as its envelope, with AdCP data in `result`
- A2A uses its native `Task`/`TaskStatusUpdateEvent` messages, with AdCP data in `status.message.parts[].data`
- Both use the **exact same data schemas** - only the envelope differs

This makes it clear that AdCP only specifies the data layer, while each protocol handles delivery in its own way.

**Schemas:**

- `static/schemas/source/core/mcp-webhook-payload.json` (renamed + simplified)
- `static/schemas/source/core/async-response-data.json` (new)
- `static/schemas/source/media-buy/*-async-response-*.json` (6 deleted, 9 remain)

- Clarified that both MCP and A2A use HTTP webhooks (A2A's is native to the spec, MCP's is AdCP-provided)
- Fixed webhook trigger rules: webhooks fire for **all status changes** if `pushNotificationConfig` is provided and the task runs async
- Added proper A2A webhook payload examples (`Task` vs `TaskStatusUpdateEvent`)
- **Task Management** added to sidebar, it was missing
10 changes: 10 additions & 0 deletions .changeset/sour-ears-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
---

Improve documentations. Specfically:

- Clarify that completed/failed statuses use Task object with data in .artifacts
- Clarify that interim statuses (working, input-required) use TaskStatusUpdateEvent with data in status.message.parts
- Add best practice guidance for URL-based routing (task_type and operation_id in URL)
- Deprecate task_type and operation_id fields in webhook payload (backward compatible)
- Update webhook handler examples to use URL parameters - Consistent guidance across both MCP and A2A protocols
109 changes: 79 additions & 30 deletions docs/protocols/a2a-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,36 @@ return { status: response.status };

A2A defines push notifications natively via `PushNotificationConfig`. When you configure a webhook URL, the server will POST task updates directly to your endpoint instead of requiring you to poll.

### Best Practice: URL-Based Routing

**Recommended:** Encode routing information (`task_type`, `operation_id`) in the webhook URL, not the payload.

**Why this approach?**
- ✅ **Industry standard pattern** - Widely adopted for webhook routing across major APIs
- ✅ **Separation of concerns** - URLs handle routing, payloads contain data
- ✅ **Protocol-agnostic** - Same pattern works for MCP, A2A, REST, future protocols
- ✅ **Cleaner handlers** - Route with URL framework, not payload parsing

**URL Pattern Options:**

```javascript
// Option 1: Path parameters (recommended)
url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}`
// Example: /webhooks/a2a/create_media_buy/op_nike_q1_2025

// Option 2: Query parameters
url: `https://buyer.com/webhooks/a2a?task=${taskType}&op=${operationId}`

// Option 3: Subdomain routing
url: `https://${taskType}.webhooks.buyer.com/${operationId}`
```

**Example Configuration:**

```javascript
const operationId = "op_nike_q1_2025";
const taskType = "create_media_buy";

await a2a.send({
message: {
parts: [{
Expand All @@ -230,7 +259,7 @@ await a2a.send({
}]
},
pushNotificationConfig: {
url: "https://buyer.com/webhooks/a2a",
url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}`,
token: "client-validation-token", // Optional: for client-side validation
authentication: {
schemes: ["bearer"],
Expand Down Expand Up @@ -318,38 +347,44 @@ if (response.status === 'working' || response.status === 'submitted') {

**Example 1: `Task` payload for completed operation**

When a task finishes, the server typically sends the full `Task` object:
When a task finishes, the server sends the full `Task` object with **task result in `.artifacts`**:

```json
{
"id": "task_456",
"contextId": "ctx_123",
"status": {
"state": "completed",
"message": {
"role": "agent",
"parts": [
{ "text": "Media buy created successfully" },
{
"data": {
"media_buy_id": "mb_12345",
"buyer_ref": "nike_q1_campaign",
"creative_deadline": "2024-01-30T23:59:59Z",
"packages": [
{ "package_id": "pkg_001", "buyer_ref": "nike_ctv_package" }
]
}
}
]
},
"timestamp": "2025-01-22T10:30:00Z"
}
},
"artifacts": [{
"name": "task_result",
"parts": [
{
"kind": "text",
"text": "Media buy created successfully"
},
{
"kind": "data",
"data": {
"media_buy_id": "mb_12345",
"buyer_ref": "nike_q1_campaign",
"creative_deadline": "2024-01-30T23:59:59Z",
"packages": [
{ "package_id": "pkg_001", "buyer_ref": "nike_ctv_package" }
]
}
}
]
}]
}
```

**CRITICAL**: For **`completed` or `failed`** status, the AdCP task result **MUST** be in `.artifacts[0].parts[]`, NOT in `status.message.parts[]`.

**Example 2: `TaskStatusUpdateEvent` for progress updates**

During execution, status changes arrive as lightweight updates:
During execution, interim status updates can include optional data in `status.message.parts[]`:

```json
{
Expand All @@ -373,7 +408,7 @@ During execution, status changes arrive as lightweight updates:
}
```

The `status.message.parts[].data` payload uses the same AdCP schemas as MCP's `result` field. Schema: [`async-response-data.json`](https://adcontextprotocol.org/schemas/v2/core/async-response-data.json)
**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/v2/core/async-response-data.json). Note that interim status schemas are evolving and may change in future versions, so implementors may choose to handle them more loosely.

### A2A Webhook Payload Types

Expand Down Expand Up @@ -426,7 +461,8 @@ Schema reference: [`async-response-data.json`](https://adcontextprotocol.org/sch
const express = require('express');
const app = express();

app.post('/webhooks/a2a', async (req, res) => {
app.post('/webhooks/a2a/:taskType/:operationId', async (req, res) => {
const { taskType, operationId } = req.params;
const webhook = req.body;

// Verify webhook authenticity (Bearer token example)
Expand All @@ -439,14 +475,27 @@ app.post('/webhooks/a2a', async (req, res) => {
return res.status(401).json({ error: 'Invalid token' });
}

// Extract data from A2A webhook payload
// Extract basic fields from A2A webhook payload
const taskId = webhook.id || webhook.taskId;
const contextId = webhook.contextId;
const status = webhook.status?.state || webhook.status;

// Get AdCP data from status.message.parts[].data
const dataPart = webhook.status?.message?.parts?.find(p => p.data);
const adcpData = dataPart?.data;

// Extract AdCP data based on status
let adcpData, textMessage;

if (status === 'completed' || status === 'failed') {
// FINAL STATES: Extract from .artifacts
const dataPart = webhook.artifacts?.[0]?.parts?.find(p => p.kind === 'data');
const textPart = webhook.artifacts?.[0]?.parts?.find(p => p.kind === 'text');
adcpData = dataPart?.data;
textMessage = textPart?.text;
} else {
// INTERIM STATES: Extract from status.message.parts (optional)
const dataPart = webhook.status?.message?.parts?.find(p => p.data);
const textPart = webhook.status?.message?.parts?.find(p => p.text);
adcpData = dataPart?.data;
textMessage = textPart?.text;
}

// Handle status changes
switch (status) {
Expand All @@ -455,7 +504,7 @@ app.post('/webhooks/a2a', async (req, res) => {
await notifyHuman({
task_id: taskId,
context_id: contextId,
message: webhook.status.message.parts.find(p => p.text)?.text,
message: textMessage,
data: adcpData
});
break;
Expand All @@ -476,7 +525,7 @@ app.post('/webhooks/a2a', async (req, res) => {
await handleOperationFailed({
task_id: taskId,
error: adcpData?.errors,
message: webhook.status.message.parts.find(p => p.text)?.text
message: textMessage
});
break;

Expand All @@ -485,7 +534,7 @@ app.post('/webhooks/a2a', async (req, res) => {
await updateProgress({
task_id: taskId,
percentage: adcpData?.percentage,
message: webhook.status.message.parts.find(p => p.text)?.text
message: textMessage
});
break;

Expand Down
Loading