When one agent asks another to do something over the Agent2Agent protocol (A2A), what travels is a message. It is the only thing a client can send to start work, add detail to work in progress, or answer a question the remote agent asked. It is also what the remote agent uses to talk back before, during and around a task. If you get the message wrong, everything built on it goes wrong too: a follow-up starts a new task instead of continuing the old one, a retry books a second flight, or a file arrives as an unreadable blob.
This article covers the Message as defined in the A2A 1.0 specification. It goes field by field, then through the unified Part, the identifier rules that attach a message to a conversation and a task, the two send operations, and the errors you will meet. The wider protocol, the task lifecycle and artifact delivery each have their own pages: A2A protocol architecture, A2A task state and A2A artifact exchange. By the end you should be able to build conformant messages and write the checks that reject bad ones.
What a message is, and what it is not
A2A separates three things that ad-hoc agent integrations tend to blur. A Message is one turn of communication, sent either by the client (role ROLE_USER) or by the agent (role ROLE_AGENT). A Task is a stateful unit of work with an id, a status and a lifecycle, created by the server in response to a message. An Artifact is the output a task produces: the report, the image, the booking confirmation.
Section 3.7 of the specification says messages SHOULD NOT deliver task outputs; results SHOULD be artifacts attached to a task. Messages start tasks, ask for clarification, report status and add input to running tasks. They are not a reliable channel: a reconnecting stream can miss status messages, and the agent decides which messages enter task history. Anything a caller must not lose belongs in an artifact.
The Message object, field by field
In A2A 1.0 the data model is defined in Protocol Buffers, and every binding (JSON-RPC, gRPC, HTTP+JSON) must carry an equivalent structure. In JSON the field names are camelCase:
| Field | Required | Meaning and rules |
|---|---|---|
messageId | yes | Unique id, for example a UUID, created by whoever creates the message. Agents MAY use it to detect duplicates. |
role | yes | ROLE_USER for client-to-server messages, ROLE_AGENT for server-to-client ones. |
parts | yes | An ordered list of Part objects: the content. At least one part is expected. |
contextId | no | Groups tasks and messages that belong to one conversation. |
taskId | no | Binds the message to an existing task. Must reference a task that exists. |
referenceTaskIds | no | Other tasks the message refers to for context, such as a previous result. |
extensions | no | URIs of extensions that this message uses. |
metadata | no | Free-form key/value map. Extensions give keys a typed meaning. |
Two design choices stand out. The creator mints messageId, so a client can generate it before sending and reuse it on retry. Task ids go the other way: they are always minted by the server, and a client cannot choose the id of a new task. This asymmetry is the basis of safe retries, which comes up again below.
Parts: one union, four kinds of content
A Part holds exactly one piece of content, chosen from four alternatives, plus optional descriptive fields. In the protobuf it is a oneof named content:
text: a string, for natural-language instructions or answers.raw: bytes, base64-encoded in JSON, for small inline files.url: a reference to content stored elsewhere, for large files or data the receiver should fetch.data: any JSON value, for structured input or output.
Alongside the content, mediaType names the format, for example image/png or application/json, filename gives a file a name, and metadata holds anything else. A message can mix parts freely. A request to analyse a photo is typically one text part with the instruction and one raw or url part with the image.
If you have used A2A 0.3, notice what changed. Version 0.3 tagged every part with a kind discriminator: {"kind": "text", "text": "..."}, or a file part with a nested file object carrying mimeType and fileWithBytes. Version 1.0 removes kind. The member that is present identifies the part, files are flattened into raw or url with filename and mediaType, and streaming events are wrapped the same way. The specification's appendix allows servers to accept both forms during a transition, but new code should emit only the 1.0 form.
Choose between raw and url by size. Base64 inflates inline bytes by about a third, and they sit in memory, logs and history on every hop. A URL keeps the message small, but the receiver needs access and the URL must outlive the task.
How identifiers bind a message to work
Section 3.4 of the specification defines what contextId and taskId mean. These rules decide whether a message starts something new or continues something old, so they are worth learning exactly:
- Neither set. The agent treats the message as new. It MAY generate a
contextIdand, if it does, MUST return it in the Task or Message it responds with. - Only
contextIdset. The message starts a new task within an existing conversation. Clients SHOULD NOT invent context ids unless they know how the server treats them. The safe pattern is to reuse the one the server returned. - Only
taskIdset. The message continues that task. The agent MUST infer the context from the task, and MUST returnTaskNotFoundErrorif the task does not exist. - Both set. They must agree. The agent MUST reject a message whose
contextIddiffers from the referenced task's context. - Task already finished. A task in
TASK_STATE_COMPLETED,TASK_STATE_FAILED,TASK_STATE_CANCELEDorTASK_STATE_REJECTEDcannot accept more messages. The agent returnsUnsupportedOperationError. To refine a finished result, start a new task in the same context and point at the old one withreferenceTaskIds.
In short, contextId is a conversation and taskId is one job inside it.
Sending: SendMessage and SendStreamingMessage
There are two ways to send. In the JSON-RPC binding the methods are SendMessage and SendStreamingMessage. In 0.3 they were message/send and message/stream. The HTTP+JSON binding uses POST /message:send and POST /message:stream. Both take a SendMessageRequest: the message, an optional configuration and optional request-level metadata. The configuration has four fields:
acceptedOutputModes: the media types the client can handle in the response. Agents SHOULD tailor output to it.historyLength: how many recent history messages to include in a returned task. Unset means no client limit, and 0 means none.returnImmediately: false by default. A blocking call waits until the task is terminal or interrupted (TASK_STATE_INPUT_REQUIRED,TASK_STATE_AUTH_REQUIRED). With true, the call returns as soon as the task exists.taskPushNotificationConfig: a webhook for updates, used only if the agent card declares push-notification support.
The response is {"task": {...}} or {"message": {...}}; a direct message suits answers that need no tracked state. A streaming call returns a sequence of StreamResponse objects, each holding exactly one of task, message, statusUpdate or artifactUpdate. If the agent creates a task, the stream starts with the Task and ends when the task reaches a terminal state. Streaming requires capabilities.streaming in the agent card; otherwise the agent returns UnsupportedOperationError. The transport details are covered in A2A streaming architecture.
Worked example: a delegation that needs more input
A planner agent delegates a booking to a travel agent. The first message has no identifiers:
{"jsonrpc": "2.0", "id": 1, "method": "SendMessage",
"params": {
"message": {
"messageId": "0b7c6a52-3f0e-4c8e-9a51-2d8f1c4e7a10",
"role": "ROLE_USER",
"parts": [
{"text": "Book one economy seat to New York on 14 October."},
{"data": {"traveller": "T-1182", "maxFare": 450, "currency": "USD"},
"mediaType": "application/json"}
]
},
"configuration": {"acceptedOutputModes": ["application/json", "text/plain"],
"historyLength": 0}
}}The travel agent creates a task, finds that it does not know the departure city and pauses. Because the call is blocking and TASK_STATE_INPUT_REQUIRED is an interrupted state, it returns now, with the question as an agent message inside the task status:
{"jsonrpc": "2.0", "id": 1, "result": {"task": {
"id": "task-5d2e", "contextId": "ctx-91af",
"status": {"state": "TASK_STATE_INPUT_REQUIRED",
"message": {"messageId": "m-agent-1", "role": "ROLE_AGENT",
"parts": [{"text": "Which city are you departing from?"}]}}}}}The planner answers with a new messageId and both identifiers, so the reply lands in the paused task rather than starting a new one:
{"message": {"messageId": "4e1f9d27-8b3a-4f5c-a6e2-7c0d9b8a3f61",
"role": "ROLE_USER", "taskId": "task-5d2e", "contextId": "ctx-91af",
"parts": [{"text": "From San Francisco (SFO)."}]}}The task completes and the confirmation arrives as an artifact. To change the booking later, the planner sends a new message in ctx-91af with "referenceTaskIds": ["task-5d2e"], because the completed task accepts no more input.
Client code: building and sending messages
This plain-HTTP client sends the A2A-Version header, which 1.0 requires, and keeps one messageId across retries:
import uuid, httpx
A2A_URL = "https://travel.example.com/a2a" # JSON-RPC endpoint from the agent card
HEADERS = {"A2A-Version": "1.0", "Authorization": "Bearer <token>"}
def make_message(parts, task_id=None, context_id=None, refs=None):
msg = {"messageId": str(uuid.uuid4()), "role": "ROLE_USER", "parts": parts}
if task_id: msg["taskId"] = task_id
if context_id: msg["contextId"] = context_id
if refs: msg["referenceTaskIds"] = refs
return msg
def send(message, attempts=3):
body = {"jsonrpc": "2.0", "id": message["messageId"], "method": "SendMessage",
"params": {"message": message,
"configuration": {"acceptedOutputModes": ["application/json"]}}}
for i in range(attempts):
try:
r = httpx.post(A2A_URL, json=body, headers=HEADERS, timeout=60)
r.raise_for_status()
out = r.json()
if "error" in out:
raise RuntimeError(out["error"]) # protocol error: do not retry blindly
return out["result"] # {"task": ...} or {"message": ...}
except (httpx.TransportError, httpx.HTTPStatusError):
if i == attempts - 1:
raise # same messageId on every attempt
result = send(make_message([{"text": "Book one economy seat to New York on 14 October."}]))Deduplication by messageId is only a MAY in the specification, so confirm your peer does it. Server-side registries are covered in A2A idempotency architecture.
Server-side validation
An agent should validate every inbound message before any model sees it. The order below rejects cheap errors first and never creates a task for a message it will refuse:
def accept(req, tenant):
m = req.message
require(m.message_id and m.role == ROLE_USER and m.parts) # InvalidParams (-32602)
if dedup.seen(tenant, m.message_id):
return dedup.previous_response(tenant, m.message_id) # optional, MAY
for part in m.parts:
if part.media_type and part.media_type not in card.input_modes:
raise ContentTypeNotSupported # -32005
if part.raw and len(part.raw) > MAX_INLINE_BYTES:
raise InvalidParams("inline file too large; send a url part")
if card.required_extensions - set(req.requested_extensions):
raise ExtensionSupportRequired # -32008
if m.task_id:
task = tasks.get(tenant, m.task_id) or raise_(TaskNotFound) # -32001
if m.context_id and m.context_id != task.context_id:
raise InvalidParams("contextId does not match task")
if task.state in TERMINAL:
raise UnsupportedOperation # -32004
return tasks.append_input(task, m)
ctx = m.context_id or new_context_id()
return tasks.create(tenant, ctx, m) # server mints task idScope every lookup by tenant or caller identity. A task id that leaks into another tenant's message must behave like an unknown id, not like a way into someone else's work.
Extensions and metadata
Metadata is a free-form map, so two peers can give one key different meanings. Extensions fix this: the message lists extension URIs in extensions, the typed data sits in metadata under that URI, and the client requests extensions with the A2A-Extensions header. Use extensions for anything a peer must interpret, and plain metadata only for hints that are safe to ignore.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Follow-up starts a fresh task | Client dropped taskId or sent only a stale contextId | Store the ids from the last response and send taskId for continuations. |
TaskNotFoundError (-32001) | Task expired, purged, or belongs to another tenant | Treat it as terminal and restart in the same context with referenceTaskIds. |
UnsupportedOperationError (-32004) | Message sent to a terminal task, or streaming not supported | Start a new task; check the agent card's capabilities. |
ContentTypeNotSupportedError (-32005) | A part's mediaType is outside the agent's input modes | Read the card's input modes and convert before sending. |
VersionNotSupportedError (-32009) | Unsupported A2A-Version (no header means 0.3) | Send the version your client implements; do not silently fall back. |
| Duplicate side effects after retry | New messageId per attempt, or a peer that does not deduplicate | Keep one id per logical send; make side-effecting skills idempotent server-side. |
Operational guidance and trade-offs
- Put results in artifacts, questions in messages. A result in a status message can be lost on reconnect and may never reach history.
- Bound inline size. Set a limit for
rawparts, for example a few hundred kilobytes, and requireurlparts above it. Use short-lived, scoped URLs and log that a part was redacted, not its bytes. - Prefer data parts over JSON in text. A
datapart with amediaTypecan be validated with a schema, whereas JSON inside atextpart invites parsing guesses. - Treat inbound text as untrusted. A message from a peer agent is input, not instructions from your operator. Keep it in the data position of your prompts and never let it change tool permissions.
What to do next
- Read the A2A 1.0 specification's sections 3.4 (multi-turn) and 3.7 (messages and artifacts), and the
MessageandPartdefinitions ina2a.proto. - Audit your client: one
messageIdper logical send,taskIdon every continuation,A2A-Versionon every request. - Audit your server against the validation order above, including the tenant scope on task lookups and the terminal-state rejection.
- Search your code for
"kind"andfileWithBytes. Those are 0.3 shapes, so plan their migration. - Move any result currently delivered in a message into an artifact, and add a size cap that forces large files onto
urlparts. - Replay the flight example against a staging agent, including a duplicate send and a message to a completed task, and confirm you get the errors in the table above.