An A2A message never travels alone. By the time a client's Message reaches a remote agent it has been wrapped in a request object, a protocol frame and an HTTP request with its own headers, and the reply comes back wrapped the same way, sometimes as a stream of separately framed events. Most interoperability bugs between agents live in these wrappers, not in the message: a missing version header that silently downgrades the protocol, a stream event correlated to the wrong request, an error parsed as a result, or metadata put at a level the other side never reads.
This article takes the envelope apart layer by layer for the Agent2Agent protocol 1.0 specification, as published in the a2aproject repository at tags v1.0.0 and v1.0.1. The Message itself, its parts and the identifier rules are covered in A2A message architecture. Here the subject is everything around it, and how to build and validate each layer.
Four layers, one request
A2A 1.0 defines its data model in Protocol Buffers and three standard bindings: JSON-RPC 2.0 over HTTP, gRPC, and HTTP+JSON (REST-style). An agent's card lists its interfaces, each with a URL, a binding and a protocol version, so a client knows which envelope to build before it sends a byte. On the JSON-RPC binding a SendMessage call has four nested layers, shown below. The HTTP+JSON binding drops layer 2 and puts the method in the URL instead, and gRPC carries the same objects as protobuf with service parameters in gRPC metadata.
Layer 1: HTTP headers and service parameters
The specification defines service parameters: key-value pairs that apply to any operation, carried as HTTP headers on the HTTP-based bindings. Two are standard. A2A-Version names the protocol version the client speaks, as Major.Minor without a patch number. A2A-Extensions lists, comma-separated, the URIs of extensions the client wants for this request.
The version header carries the most dangerous default in the protocol. The specification says clients must send it, and that agents must interpret an empty value as version 0.3. A 1.0 client whose HTTP library or gateway drops the header is therefore treated as a 0.3 client, and depending on the server, it gets 0.3 semantics or a VersionNotSupportedError. Agents that do not support the requested version must return that error. Clients may also pass the version as a query parameter. Content types differ by binding: application/json for JSON-RPC, and for HTTP+JSON the v1.0.1 text says application/a2a+json SHOULD be used, while v1.0.0 said application/json, so servers should accept both.
Authentication sits alongside these headers, usually as a bearer token, with the scheme declared in the agent card; see the agent card.
Layer 2: the binding frame
On JSON-RPC, the body is a standard JSON-RPC 2.0 request with four members: "jsonrpc": "2.0", an id, a method and params. Method names in 1.0 are PascalCase and mirror the gRPC service: SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask and so on. Version 0.3 used slash names such as message/send; mixing them up produces a MethodNotFound error (-32601). One base-structure example in the specification still shows a category/action placeholder, but the normative method list is PascalCase.
The id is the correlation key for everything that comes back, including every event of a stream. Always send one, and make it unique per request; a JSON-RPC request without an id is a notification, to which a server must not reply at all. On HTTP+JSON there is no frame: the operation is the URL and verb, such as POST /message:send, POST /message:stream or GET /tasks/{id}, and the body is the request object directly.
Layer 3: SendMessageRequest, and three levels of metadata
The params object is a SendMessageRequest with four fields. message is required. configuration holds accepted output modes, history length, returnImmediately and a push-notification config; the message article covers each. tenant is an opaque routing value: when the chosen interface in the agent card declares a tenant, the client must copy it into every request. metadata is a free-form map for the request as a whole.
That makes three places to put metadata, and choosing wrongly is a common interoperability bug:
| Level | Scope | Put here |
|---|---|---|
| Request metadata | This one call | Tracing context, client build, request-scoped hints |
| Message metadata | This turn, stored in task history | Facts about the turn that later readers need |
| Part metadata | One piece of content | Provenance or rendering hints for that part |
Request metadata is not part of the conversation; message metadata can end up in task history and be returned to other clients who read the task. Never put credentials or personal data in message or part metadata. Extensions give typed meaning to metadata keys, and a client activates them through the A2A-Extensions header; if the agent card marks an extension as required and the client does not declare it, the server returns ExtensionSupportRequiredError.
The response envelope
A successful SendMessage reply is a JSON-RPC response with the same id and a result holding a SendMessageResponse. That object is a oneof: exactly one of task or message. A direct message suits a quick answer; a task means the agent created or continued stateful work. A client must branch on which member is present and treat both or neither as a protocol violation, not guess from the fields. On HTTP+JSON the body is the same object without the JSON-RPC frame.
The stream envelope
SendStreamingMessage returns HTTP 200 with text/event-stream. On the JSON-RPC binding, every Server-Sent Events data line is a complete JSON-RPC response carrying the original request id, whose result is a StreamResponse. StreamResponse is another oneof, with four members: task, message, statusUpdate and artifactUpdate. On HTTP+JSON each data line is the bare StreamResponse.
The order is fixed by the specification: first a Task, or a single Message, then status and artifact updates until the task reaches a terminal or interrupted state, when the stream closes. Events must not be reordered. Two details changed from 0.3. The status update event has no final flag in 1.0: it carries taskId, contextId, status and optional metadata, and the end of the stream is the signal. Artifact updates keep append and lastChunk so that a large artifact can arrive in pieces that the client concatenates by artifactId. Stream reconnection and push delivery are covered in A2A streaming.
The error envelope
On JSON-RPC, errors replace result with an error object holding a numeric code, a human-readable message and an optional data array. Each entry in data must have an @type key, and the specification recommends well-known google.rpc types such as ErrorInfo and BadRequest:
{"jsonrpc": "2.0", "id": "req-8",
"error": {"code": -32001, "message": "Task not found",
"data": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "TASK_NOT_FOUND", "domain": "a2a-protocol.org",
"metadata": {"taskId": "t-41"}}]}}| Code | A2A error |
|---|---|
| -32001 | TaskNotFoundError |
| -32002 | TaskNotCancelableError |
| -32003 | PushNotificationNotSupportedError |
| -32004 | UnsupportedOperationError |
| -32005 | ContentTypeNotSupportedError |
| -32006 | InvalidAgentResponseError |
| -32007 | ExtendedAgentCardNotConfiguredError |
| -32008 | ExtensionSupportRequiredError |
| -32009 | VersionNotSupportedError |
The standard JSON-RPC codes still apply for parse errors (-32700), invalid requests (-32600), unknown methods (-32601), invalid params (-32602) and internal errors (-32603). On HTTP+JSON the body is a google.rpc.Status wrapped in an error object, and because several A2A errors share one HTTP status, an ErrorInfo detail with domain a2a-protocol.org and a reason such as TASK_NOT_FOUND is mandatory. Key client logic on that reason, not on the HTTP status: the status mapping table differs between the v1.0.0 and v1.0.1 texts, for example 409 against 400 for TaskNotCancelableError. One example in the specification uses an application/problem+json body instead; parse defensively. Retry policy for these errors is covered in A2A error handling.
A client that validates every layer
The code below builds the frame, sends a streaming request and unwraps each event, rejecting anything that belongs to another request, carries an error, or violates the oneof rule. It uses httpx and nothing from an A2A SDK, so every layer is visible:
import json, uuid
import httpx
A2A_VERSION = "1.0"
PAYLOAD_KEYS = ("task", "message", "statusUpdate", "artifactUpdate")
class A2AError(Exception):
def __init__(self, code, message, data):
super().__init__(f"{code} {message}")
self.code, self.data = code, data or []
self.reason = next((d.get("reason") for d in self.data
if d.get("@type", "").endswith("google.rpc.ErrorInfo")), None)
def frame(method, params):
return {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": method, "params": params}
def send_params(text, context_id=None, task_id=None, request_metadata=None):
msg = {"messageId": str(uuid.uuid4()), "role": "ROLE_USER", "parts": [{"text": text}]}
if context_id: msg["contextId"] = context_id
if task_id: msg["taskId"] = task_id
params = {"message": msg}
if request_metadata: params["metadata"] = request_metadata
return params
def unwrap(reply, expected_id):
if reply.get("jsonrpc") != "2.0" or reply.get("id") != expected_id:
raise ValueError("reply does not belong to this request")
if "error" in reply:
e = reply["error"]
raise A2AError(e["code"], e.get("message", ""), e.get("data"))
result = reply["result"]
present = [k for k in PAYLOAD_KEYS if k in result]
if len(present) != 1: # the payload is a oneof
raise ValueError(f"expected exactly one payload, got {present}")
return present[0], result[present[0]]
def stream(url, token, text, **kw):
body = frame("SendStreamingMessage", send_params(text, **kw))
headers = {"Content-Type": "application/json", "Accept": "text/event-stream",
"Authorization": f"Bearer {token}", "A2A-Version": A2A_VERSION}
with httpx.stream("POST", url, json=body, headers=headers, timeout=None) as r:
r.raise_for_status()
data = []
for line in r.iter_lines():
if line.startswith("data:"):
data.append(line[5:].lstrip())
elif line == "" and data: # a blank line ends one SSE event
yield unwrap(json.loads("\n".join(data)), body["id"])
data = []On the server, validate in the same outside-in order and fail at the first broken layer: authentication, then the version header, then JSON parsing (-32700), frame shape (-32600), method name (-32601), params against the schema (-32602, with a BadRequest detail naming the field), required extensions, and only then the business rules such as whether the task exists. Failing early keeps error codes precise, and never letting a request reach the agent's model with an unvalidated envelope keeps malformed input out of prompts.
A worked wire trace
Here is one streaming call end to end. The client sends request metadata with a trace context, the agent creates task t-42 in a new context and streams an artifact and a completion:
POST /a2a/v1 HTTP/1.1
Content-Type: application/json
Accept: text/event-stream
Authorization: Bearer eyJ...
A2A-Version: 1.0
{"jsonrpc": "2.0", "id": "req-7", "method": "SendStreamingMessage",
"params": {"message": {"messageId": "m-1", "role": "ROLE_USER",
"parts": [{"text": "Summarise Q3 incidents"}]},
"metadata": {"traceparent": "00-4bf9...-01"}}}
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"jsonrpc": "2.0", "id": "req-7", "result": {"task": {"id": "t-42", "contextId": "c-9", "status": {"state": "TASK_STATE_WORKING"}}}}
data: {"jsonrpc": "2.0", "id": "req-7", "result": {"artifactUpdate": {"taskId": "t-42", "contextId": "c-9", "artifact": {"artifactId": "a-1", "parts": [{"text": "Three incidents..."}]}, "lastChunk": true}}}
data: {"jsonrpc": "2.0", "id": "req-7", "result": {"statusUpdate": {"taskId": "t-42", "contextId": "c-9", "status": {"state": "TASK_STATE_COMPLETED"}}}}Every event repeats id req-7, so a client multiplexing several streams can route them. The first event is the Task snapshot, which gives the client the server-minted task and context ids it must use for any follow-up. The artifact arrives in one chunk, so lastChunk is true. The completed status is the last event, and the server then closes the stream; there is no separate end marker.
Failure modes
- Dropped version header. A proxy or SDK default strips A2A-Version and the agent falls back to 0.3 behaviour. Log the version the server saw on every request.
- 0.3 shapes in a 1.0 envelope. Slash method names, kind discriminators on parts or a final flag on status events come from old examples. Validate against the 1.0 schema in tests.
- Uncorrelated stream events. Clients that ignore the id mix up events when requests share a connection pool or a proxy replays a stream.
- Buffered SSE. Gateways that buffer responses turn a stream into one late burst. Disable buffering for text/event-stream routes.
- Status-code error handling. JSON-RPC errors arrive in the body, and HTTP+JSON status mappings moved between patch releases. Branch on the error code or ErrorInfo reason.
- Sensitive data in message metadata. It is stored in history and returned to anyone who can read the task.
What to do next
- Check each agent card interface's binding and protocolVersion, and build the matching envelope.
- Send A2A-Version on every request, and assert on the server side that it arrived; alert on empty values.
- Add the unwrap checks above to your client: jsonrpc version, matching id, error-before-result and exactly one payload member.
- Move tracing and client hints to request metadata and audit message and part metadata for secrets.
- Map errors by JSON-RPC code or ErrorInfo reason, and write a test for each of the nine A2A error codes.
- Run a streaming call through every proxy on the path and confirm events arrive incrementally and in order.