When people say "A2A message types" they usually mean one of three things: the kinds of content a message part can hold, the roles a message can have, or the set of objects that can come back from a remote agent. The first two are covered field by field in A2A Message Architecture in Depth. This article is about the third and about the design decision it forces on both sides of the wire: which object should carry which piece of content.
Get this wrong and the protocol still works, which is the problem. An agent that puts its final report in a status message, or a client that ignores artifact chunks marked append, interoperates in a demo and loses data in production. Facts here follow the A2A specification v1.0.0 at a2a-protocol.org as checked on 2026-10-01; where 0.3 differed it is noted.
Six shapes on the wire
A client sends one thing: a Message with role ROLE_USER and one or more parts. What comes back is drawn from a small closed set. A non-streaming SendMessage returns either a task or a message. A streaming call returns a sequence of StreamResponse objects, each holding exactly one of four members: task, message, statusUpdate or artifactUpdate. Inside those, two more objects carry content: the agent's Message (in TaskStatus.message or as a direct reply) and the Artifact.
| Object | Sent by | Carries | Lifetime |
|---|---|---|---|
| Message, ROLE_USER | Client | Task request, answers to questions, follow-ups | One turn |
| Message, ROLE_AGENT (direct) | Server | A complete answer that needs no tracking | One turn; no task exists |
| Task | Server | id, contextId, status, artifacts, optional history | Until a terminal state |
| TaskStatus.message | Server | Progress notes, clarification questions, failure explanation | Replaced by the next status |
| Artifact | Server | The deliverable: documents, data, files | Persisted on the task |
| statusUpdate / artifactUpdate | Server, streaming | Deltas to the task above | Ephemeral events |
A message's parts use one union: text, raw bytes, a url, or structured data, each with an optional mediaType and filename. Roles are ROLE_USER and ROLE_AGENT. Task states are SUBMITTED, WORKING, INPUT_REQUIRED, AUTH_REQUIRED, COMPLETED, FAILED, CANCELED and REJECTED, each prefixed TASK_STATE_ in 1.0.
The rule: communication in Messages, results in Artifacts
Section 3.7 of the specification lists the jobs messages do: task initiation, clarification, status and interaction. It then says messages should not be used to deliver task outputs, and that results should be returned using Artifacts associated with a Task, to keep communication and data output distinct. That one sentence decides most routing questions:
- A question the agent needs answered goes in
status.messagewith stateTASK_STATE_INPUT_REQUIRED. The client replies with a new ROLE_USER message carrying the sametaskId. - Progress goes in
status.messageon aTASK_STATE_WORKINGupdate. It is for humans and logs, and it is overwritten by the next status. - The deliverable, even if it is one sentence, goes in an Artifact. Downstream agents and storage read artifacts, not status text.
- Why it failed goes in
status.messageon theTASK_STATE_FAILEDstatus, with structured detail in a data part if the client can act on it. - A quick stateless answer may be a direct Message instead of a Task; the server then creates no task at all.
The reason is durability and addressability. Artifacts have identifiers, names and a place on the task that survives reconnection and GetTask calls. Status messages are replaced each time the status changes, so content placed there can vanish before a slow client reads it.
The routing picture
Server side: Message or Task?
Returning a direct Message means no task is created, so the client has nothing to poll, cancel, resubscribe to or attach push notifications to. That is right for cheap, side-effect-free answers: a lookup, a validation result, a capability question. Anything that takes noticeable time, may need input, has side effects or produces something worth storing should be a Task. A useful test is whether you would ever want to cancel it or ask about it later; if so, it is a task.
def respond(request, ctx) -> dict:
"""Return {"message": ...} for stateless answers, {"task": ...} for tracked work."""
msg = request["message"]
if msg.get("taskId"): # continuing existing work
task = store.get(msg["taskId"])
if task is None:
raise TaskNotFound(msg["taskId"])
if task["status"]["state"] in TERMINAL:
raise UnsupportedOperation("task is terminal; start a new task in the same contextId")
return {"task": executor.resume(task, msg)}
plan = planner.classify(msg)
if plan.kind == "answer" and plan.cost_ms < 2000 and not plan.side_effects:
return {"message": {
"messageId": new_id(), "role": "ROLE_AGENT",
"contextId": msg.get("contextId") or new_id(),
"parts": [{"text": plan.answer}],
}}
task = store.create(context_id=msg.get("contextId") or new_id(), first_message=msg)
executor.start(task, plan) # emits statusUpdate / artifactUpdate events
return {"task": task}
TERMINAL = {"TASK_STATE_COMPLETED", "TASK_STATE_FAILED",
"TASK_STATE_CANCELED", "TASK_STATE_REJECTED"}Two rules in that sketch come straight from the specification. A message carrying a taskId continues that task. A message sent to a task in a terminal state is rejected with UnsupportedOperationError; to continue the conversation the client starts a new task in the same contextId. Keep the decision in one function and log which branch it took, because clients see the difference as a different response shape. Whatever thresholds you use, publish the behaviour in the skill descriptions of your agent card so callers know whether to expect a task.
Choosing part kinds inside each object
Within Messages and Artifacts the same part union applies, but the right choice differs. In status messages, use text for people and add a data part when the client must act, for example a list of missing fields when asking for input. In artifacts, choose by consumer: data with application/json for anything another program will parse, text with a media type such as text/csv or text/markdown for textual documents, raw bytes for small binaries, and url for large files the client should fetch. Honour the client's acceptedOutputModes; if you cannot produce an acceptable type, say so rather than sending one the client will drop.
One artifact can hold several parts, for example a JSON summary and a rendered Markdown version of the same result. Prefer that to two artifacts when the parts are representations of one thing, and separate artifacts when they are separate deliverables with their own names. Field-level detail of each part kind is in the A2A artifact architecture.
Client side: reduce the stream into one view
A client should not handle each event type ad hoc. It should fold every StreamResponse into a single task view, the same view it would get from GetTask, so that reconnection, polling and streaming all produce the same state. The rules are: a task event is a snapshot and replaces state; a statusUpdate replaces status; an artifactUpdate either creates an artifact or, when append is true, extends the parts of the artifact with that artifactId; lastChunk marks the artifact complete; a message is a direct answer.
INTERRUPTED = {"TASK_STATE_INPUT_REQUIRED", "TASK_STATE_AUTH_REQUIRED"}
class TaskView:
def __init__(self):
self.task_id = None; self.context_id = None
self.state = None; self.status_message = None
self.artifacts = {} # artifactId -> {"name":..., "parts": [...], "complete": bool}
self.direct_message = None
self.events = 0
def apply(self, ev: dict):
self.events += 1
kinds = [k for k in ("task", "message", "statusUpdate", "artifactUpdate") if k in ev]
if len(kinds) != 1:
raise ValueError(f"StreamResponse must hold exactly one member, got {kinds}")
k = kinds[0]; v = ev[k]
if k == "message": # stateless answer, or a message outside any task
self.direct_message = v
elif k == "task": # snapshot: replace, never merge
self.task_id, self.context_id = v["id"], v["contextId"]
self.state = v["status"]["state"]; self.status_message = v["status"].get("message")
self.artifacts = {a["artifactId"]: {"name": a.get("name"), "parts": list(a["parts"]),
"complete": True} for a in v.get("artifacts", [])}
elif k == "statusUpdate":
self._check_task(v)
self.state = v["status"]["state"]
self.status_message = v["status"].get("message") # agent Message: progress or a question
else: # artifactUpdate
self._check_task(v)
art = v["artifact"]; aid = art["artifactId"]
slot = self.artifacts.get(aid)
if v.get("append") and slot:
slot["parts"].extend(art["parts"])
else:
slot = self.artifacts[aid] = {"name": art.get("name"), "parts": list(art["parts"])}
slot["complete"] = bool(v.get("lastChunk"))
def _check_task(self, v):
if self.task_id and v["taskId"] != self.task_id:
raise ValueError("event for a different task on this stream")
@property
def done(self):
return self.direct_message is not None or self.state in TERMINAL | INTERRUPTEDIn 1.0 the status update event has no final flag; the stream ends when the task reaches a terminal or interrupted state and the server closes it. Clients ported from 0.3, where a final flag and a kind discriminator existed, must switch to checking state and stream closure, and must discriminate by which member is present. Raising on an event that holds zero or two members is deliberate: it is a protocol violation, and silently guessing hides server bugs. For the transport around this loop see A2A streaming architecture.
A worked stream
A finance agent is asked for a quarter's variance report. It returns a task and streams six events:
1 {"task": {"id": "t-7", "contextId": "c-2", "status": {"state": "TASK_STATE_SUBMITTED"}}}
2 {"statusUpdate": {"taskId": "t-7", "contextId": "c-2", "status": {"state": "TASK_STATE_WORKING",
"message": {"messageId": "m-9", "role": "ROLE_AGENT", "parts": [{"text": "Pulling Q3 ledgers"}]}}}}
3 {"artifactUpdate": {"taskId": "t-7", "contextId": "c-2",
"artifact": {"artifactId": "a-1", "name": "variance.csv",
"parts": [{"text": "account,expected,actual\n4100,1200,1180\n", "mediaType": "text/csv"}]},
"append": false, "lastChunk": false}}
4 {"artifactUpdate": {"taskId": "t-7", "contextId": "c-2",
"artifact": {"artifactId": "a-1", "parts": [{"text": "4200,800,950\n", "mediaType": "text/csv"}]},
"append": true, "lastChunk": true}}
5 {"artifactUpdate": {"taskId": "t-7", "contextId": "c-2",
"artifact": {"artifactId": "a-2", "name": "summary",
"parts": [{"data": {"accounts": 2, "flagged": ["4200"]}, "mediaType": "application/json"}]},
"lastChunk": true}}
6 {"statusUpdate": {"taskId": "t-7", "contextId": "c-2", "status": {"state": "TASK_STATE_COMPLETED"}}}Folding them through the reducer gives this sequence of views. After event 1 the task exists in SUBMITTED with no artifacts. After event 2 the state is WORKING and the status message reads "Pulling Q3 ledgers", which a UI can display. Event 3 creates artifact a-1 with the CSV header and one row, marked incomplete. Event 4 has append true, so its row is added to the same artifact, which is now complete. Event 5 creates a second artifact with a JSON summary that downstream code can parse without reading the CSV. Event 6 sets COMPLETED; the server closes the stream and done becomes true.
Note what the agent did not do: it did not put the summary in the final status message. A client that reconnects after event 6 and calls GetTask gets both artifacts from the stored task; a status message saying "two accounts, 4200 flagged" would be the only thing such a client could not reconstruct reliably. Had the agent needed the reporting currency, it would have sent a status update with INPUT_REQUIRED and a message asking for it, the stream would have closed, and the client would have sent a new ROLE_USER message with taskId t-7 to resume.
Failure modes
- Results in status messages. The next status overwrites them and stored tasks lack the deliverable. Emit an artifact, even for one line.
- Ignoring append. The client replaces chunk one with chunk two and keeps half a file. Key artifacts by
artifactIdand honourappend. - Treating a direct Message as an error. Clients written only for tasks crash or retry. Handle both members of the response.
- Waiting for a final flag. 0.3-era clients hang on 1.0 servers. End on terminal or interrupted state and stream closure.
- Messaging a finished task. The server returns
UnsupportedOperationError. Start a new task in the samecontextId. - Role confusion. A proxy agent forwards remote ROLE_AGENT messages upstream as if the user wrote them, which mixes trust levels. Keep the role and wrap remote content as data.
Operational guidance and trade-offs
Instrument the shapes. Count direct Messages against Tasks per skill, artifacts per completed task, chunks per artifact, and tasks that complete with zero artifacts; the last is almost always a routing bug. Log the event sequence per task id so you can replay a stream into the reducer when a client reports missing output. Test the reducer with recorded streams that include reordering at reconnection boundaries and a task snapshot arriving mid-stream.
The trade-off between direct Messages and Tasks is simplicity against control. Messages are cheaper and faster but cannot be cancelled, resumed or audited later; tasks cost a store write and some latency but give every downstream feature a handle. When in doubt, return a task. For the store and executor behind tasks see Agent-to-Agent Task Architecture, and for the layers around each object see the A2A message envelope.
What to do next
- List every piece of content your agent emits and assign it to a Message, a status message or an Artifact using the section 3.7 rule.
- Move any deliverable currently sent in a status message into an artifact with a stable name.
- Put the Message-or-Task decision in one server function, log the branch, and describe the behaviour in your agent card skills.
- Implement a single reducer on the client that handles all four StreamResponse members, append and lastChunk, and use it for streaming, polling and push alike.
- Remove any dependence on a final flag or kind field if you started on 0.3.
- Add metrics for tasks completing without artifacts and for direct Message rates per skill.