Skip to content

Spec: formalize the AdCP-over-A2A wire binding #3052

Description

@bokelley

Context

AdCP 3.0 defines the transport-neutral envelope (`protocol-envelope.json`), request/response schemas per tool, and the `task_id` / `status: 'submitted'` pattern for async work. It does not define how an AdCP agent is exposed over A2A JSON-RPC specifically — only over MCP.

As A2A-native buyer agents emerge (Google's agent-to-agent tooling, Scope3 harnesses, others), AdCP sellers implementing both transports will diverge on three decisions that the spec doesn't pin:

  1. How does a Message address a skill? A2A's `Message` carries free-form `parts[]`. AdCP tools need a routing discriminant. There is no de-facto convention across implementations.
  2. Where does `task_id` live when an AdCP handler returns a Submitted arm over A2A? The AdCP payload has a `task_id` field. A2A also has its own `Task.id` generated by the server. Are they the same? Does the AdCP handle ride inside the DataPart `data`, inside `artifact.metadata`, or somewhere else?
  3. What `Task.state` does the adapter emit when the AdCP work is async-pending? A2A 0.3.0 treats `submitted` as the INITIAL lifecycle state before `working`, not a terminal state. Emitting `state: 'submitted'` with `final: true` on a synchronous `message/send` that acknowledges a queued AdCP task is non-conformant per A2A's lifecycle, but it's the obvious-looking mapping.

`@adcp/client` just shipped an A2A adapter (adcontextprotocol/adcp-client#899) that made choices on all three. The choices matter: same-SDK client/server mismatch on (1) would have been a silent routing failure; wrong-choice on (3) would confuse Google's A2A tooling. Reviewers on that PR flagged that without a spec binding, other implementations will choose differently.

Proposal

Publish a normative AdCP-over-A2A binding in the spec alongside the MCP path. Proposed rules, matching what `@adcp/client`'s adapter ships:

Skill addressing

A client invokes an AdCP tool by sending a `Message` with exactly one `DataPart`:

```json
{ "kind": "data", "data": { "skill": "<tool_name>", "input": { ... } } }
```

  • `skill` (required, string) matches an AdCP tool name (`get_products`, `create_media_buy`, etc.).
  • `input` (required, object) carries the tool's typed AdCP request payload as defined by the existing per-tool request schemas.
  • Multi-part Messages, text-only Messages, and Messages missing the `skill` field are routing errors (A2A JSON-RPC `-32602` or AdCP `VALIDATION_ERROR` inside a failed Task, per implementation).

Response mapping

Per handler-return → A2A surface:

AdCP return A2A `Task.state` Artifact
Success arm `completed` `DataPart` with the typed AdCP response
Submitted arm (`status: 'submitted'`) `completed` `DataPart` with the AdCP response; `artifact.metadata.adcp_task_id` = the AdCP-level `task_id`
Error arm (`errors[]`) `failed` `DataPart` with the Error arm payload
Framework error (`adcp_error`) `failed` `DataPart` with `adcp_error`

Rationale for Submitted → `completed`

A2A `Task.state` tracks the transport call lifecycle (did the JSON-RPC request complete?). AdCP `status` inside the artifact tracks the work (submitted / completed / failed). A Submitted AdCP arm on a synchronous `message/send` means the transport call finished; the ad-tech work is queued. Buyers resume the AdCP work via `artifact.metadata.adcp_task_id`. This keeps the A2A Task lifecycle clean and lets the two state machines compose without conflict.

Rationale for `artifact.metadata` (not `data`)

`artifact.data` should validate against the AdCP tool's response schema. Injecting transport-level metadata (`adcp_task_id`) into `data` pollutes that surface. `Artifact.metadata` is A2A 0.3.0's spec-sanctioned extension channel. Keeping the two clean means storyboards can `model_validate` the artifact payload against AdCP schemas without special-casing transport fields.

Adoption path

  • Draft the binding as a new section under `schemas/cache//protocols/` (or as a sibling doc).
  • Reference it from each protocol's existing bindings doc (currently MCP-implicit).
  • Call out the `Task.state` lifecycle rationale explicitly so adapter authors don't re-derive it.

References

Happy to iterate on the exact mapping language; the three decisions above are the axes that need pinning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-triagedIssue has been triaged by the Claude Code triage routine. Remove to re-triage.enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions