A2A Is the Task

Published:

Every conversation I have about A2A eventually arrives at the same question: couldn’t you just do this with MCP? The honest answer is “mostly, yes,” and I think A2A fans would be better served by saying so out loud. In this month’s deep dive I set out to figure out what’s doable in A2A that you can’t do with MCP. I think the approach to tasks really highlights the nature of how these two protocols approach agentic interaction, and that’s the interesting part in my opinion. Unfortunately, it gets lost when we pretend the two protocols have nothing in common.

In this post I attempt to isolate that part. In short: A2A has one primitive that MCP does not have in the same shape, and it is the Task. With small housekeeping exceptions, anything in A2A either produces a task, feeds one, or watches one. As the centerpiece of A2A, I’ll explore this object and its state machine in depth. As in other posts on my blog, there are interactive components you can use to navigate the state machine yourself, so feel free to drive the tasks as you see fit!

Disclosure, as usual: I work at Arcade, where my days are mostly MCP. I’m also an AAIF ambassador, which motivated me to look closer at A2A, and I am not neutral about any of this. I am, however, trying to be accurate, and every claim about the protocols below is grounded in the A2A specification and the MCP Tasks extension.

The “MCP vs A2A” conversation

Most “MCP vs A2A” conversations take the same basic shape. Take an agent. Put an MCP server in front of it with a single tool, ask_agent(prompt), that forwards the prompt and returns whatever the agent says. Now list what you have:

  • Discovery. tools/list tells the caller what the agent can do. Add a description and you have something that looks a lot like an Agent Card.
  • Skills. One tool per skill, each with an input schema. Depending on the implementation, it’s even more precise than A2A’s prose-and-tags skills.
  • Auth. OAuth at the transport.
  • Long-running work. A former MCP limitation, now the MCP Tasks extension (io.modelcontextprotocol/tasks) gives you a durable task handle, working and input_required and terminal states, polling with tasks/get, a way to answer mid-flight questions with tasks/update, cooperative cancellation, and push notifications. It is very much happening, and it is good.

So what’s left? Let’s look at the same request in both envelopes.

Same prompt as

Request

{  "jsonrpc": "2.0",  "id": 1,  "method": "SendMessage",  "params": {    "message": {      "messageId": "msg-01",      "role": "ROLE_USER",      "parts": [        {          "text": "Chart last quarter’s churn by month."        }      ]    }  }}

Response

{  "jsonrpc": "2.0",  "id": 1,  "result": {    "task": {      "id": "task-01",      "contextId": "ctx-3e8a",      "status": {        "state": "TASK_STATE_SUBMITTED",        "timestamp": "2026-08-28T14:02:11Z"      },      "history": [        {          "messageId": "msg-01",          "role": "ROLE_USER",          "parts": [            {              "text": "Chart last quarter’s churn by month."            }          ]        }      ],      "artifacts": []    }  }}
Highlighted fields exist only on this side. The A2A task is a container: a context it belongs to, a history of messages so far, and a list of artifacts that will fill in.
Text version: the same request as an MCP tool call and as an A2A message

MCP: tools/call with the Tasks extension

// request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "ask_analyst",
    "arguments": {
      "prompt": "Chart last quarter’s churn by month."
    },
    "_meta": {
      "io.modelcontextprotocol/clientCapabilities": {
        "extensions": {
          "io.modelcontextprotocol/tasks": {}
        }
      }
    }
  }
}

// response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "task",
    "task": {
      "taskId": "mcp-7c2e",
      "status": "working",
      "ttlMs": 600000,
      "pollIntervalMs": 2000,
      "createdAt": "2026-08-28T14:02:11Z"
    }
  }
}

A2A: SendMessage

// request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "msg-01",
      "role": "ROLE_USER",
      "parts": [
        {
          "text": "Chart last quarter’s churn by month."
        }
      ]
    }
  }
}

// response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "task": {
      "id": "task-01",
      "contextId": "ctx-3e8a",
      "status": {
        "state": "TASK_STATE_SUBMITTED",
        "timestamp": "2026-08-28T14:02:11Z"
      },
      "history": [
        {
          "messageId": "msg-01",
          "role": "ROLE_USER",
          "parts": [
            {
              "text": "Chart last quarter’s churn by month."
            }
          ]
        }
      ],
      "artifacts": []
    }
  }
}
FieldPresent inMeaning
resultType: "task"MCP onlyTells the client the response is a task handle rather than the tool result.
ttlMs, pollIntervalMsMCP onlyHow long the result stays fetchable and how often to poll.
contextIdA2A onlyThe conversation this task belongs to. Other tasks can share it.
historyA2A onlyMessages exchanged so far, from both sides.
artifactsA2A onlyOutputs. Empty now; fills in over the life of the task, possibly in chunks.
statusBothMCP: a bare string. A2A: an object with state, timestamp and an optional message.

The MCP task is a receipt. It stands in for one request, it has a TTL, and when it completes, its result is exactly what that one request would have returned if it had been fast. The A2A task is a thread. It belongs to a context that other tasks can share, it carries the history of messages from both sides, and its outputs are a list that fills in over time. Both are called tasks. They are wrapping different things.

Here is the full comparison, including the rows where the answer is “same”:

MCP taskA2A task
What it wrapsOne request. result is what the request would have returned synchronously.A collaboration: a history of messages from both sides, plus zero or more artifacts.
Who decides to create oneThe server, per request.The agent, per message (the Message-or-Task fork).
Pause for inputinput_required, answered via tasks/update with structured inputResponses.input-required, answered with a new Message carrying the same taskId. Free-form, multimodal.
Pause for authNo state of its own: input_required plus a URL-mode elicitation, consent out of band.auth-required is a first-class task state.
DeclineBy convention: an error code, or refusal text in a result. No standard refusal state.rejected, distinct from failed.
Output arrivesOnce, at completed.Incrementally, as TaskArtifactUpdateEvent chunks with append and lastChunk.
Relationship between tasksNone. The handle ties operations to one task, never tasks to each other.contextId groups them; referenceTaskIds links refinements.
After terminalResult stays fetchable until the TTL expires.Closed forever. A refinement is a new task in the same context.
CancelCooperative.Cooperative; TaskNotCancelableError when refused.
DeliveryPoll tasks/get, or notifications/tasks.Poll GetTask, stream, or push to a webhook.

From that table we can see how the two protocols approach tasks in a way that reminds me of parallel evolution.

  • Convergent traits: pausing for input and cooperative cancel are nearly identical. Pausing for auth and declining are both expressible in MCP, as conventions layered on input_required and error codes. A2A achieves the same with a stronger contract, giving each a named state instead.
  • Divergent traits: A2A allows for partial delivery and retains the context of one or multiple tasks that you can group. MCP does not have a convention for this at all.

In the scenarios below I walk through A2A-optimized task “threads”. A2A is a natural choice for some of these scenarios, which would be difficult to generalize using MCP.

The pieces on the board

I present 7 distinct scenarios below, but the actors are all the same:

  • Your agent. The client. A personal agent that runs on your behalf and delegates work it can’t do itself.
  • The Analyst. The remote agent. Run by another team, it has access to the data warehouse and, if you allow it, the billing system. It is the A2A server in every scenario.
  • The warehouse and the billing system. Behind the Analyst. You never talk to them, but they cause most of the interesting states.
  • Message, Part, Task, TaskStatus, Artifact. The nouns. A Message is what either side says, made of Parts (text, file, structured data). A Task is the unit of work. TaskStatus is its current state plus an optional message explaining that state. An Artifact is an output.
  • contextId and taskId. The context is the conversation. The task is one piece of work inside it. Several tasks can live in one context.

Setup: the Agent Card

Like MCP, A2A has a discovery step that allows the client and server agent to negotiate capabilities and expectations of their interactions. The Analyst publishes an Agent Card at /.well-known/agent-card.json. Your (client) agent fetches it once.

A
A2A agent card
Analyst
v2.3.0
Identity
Answers questions about product usage, churn and revenue from the warehouse and the billing system.
Provided by Data Platform Team · https://analyst.example
Reach it at
JSONRPC https://analyst.example/a2a · protocol 1.0
Capabilities
extendedAgentCard false
To talk to it
bearer HTTP auth · bearer
Skills
  • Churn analysis Churn rates by month, region, cohort or plan.
    analyticschurn
  • Revenue joins Joins usage metrics with billing revenue. Requires your consent for billing access.
    analyticsbilling
  • Aggregate exports CSV exports of aggregates. Never exports PII.
    export
GET https://analyst.example/.well-known/agent-card.json
{  "name": "Analyst",  "description": "Answers questions about product usage, churn and revenue from the warehouse and the billing system.",  "supportedInterfaces": [    {      "url": "https://analyst.example/a2a",      "protocolBinding": "JSONRPC",      "protocolVersion": "1.0"    }  ],  "provider": {    "url": "https://analyst.example",    "organization": "Data Platform Team"  },  "version": "2.3.0",  "capabilities": {    "streaming": true,    "pushNotifications": true,    "extendedAgentCard": false  },  "securitySchemes": {    "bearer": {      "httpAuthSecurityScheme": {        "scheme": "bearer"      }    }  },  "securityRequirements": [    {      "schemes": {        "bearer": {          "list": []        }      }    }  ],  "defaultInputModes": [    "text/plain"  ],  "defaultOutputModes": [    "text/plain",    "text/csv"  ],  "skills": [    {      "id": "churn",      "name": "Churn analysis",      "description": "Churn rates by month, region, cohort or plan.",      "tags": [        "analytics",        "churn"      ]    },    {      "id": "revenue",      "name": "Revenue joins",      "description": "Joins usage metrics with billing revenue. Requires your consent for billing access.",      "tags": [        "analytics",        "billing"      ]    },    {      "id": "export",      "name": "Aggregate exports",      "description": "CSV exports of aggregates. Never exports PII.",      "tags": [        "export"      ]    }  ]}
Hover a section of the card, or a line of the JSON.
The Analyst’s Agent Card, as a card and as the JSON your agent actually fetches. The two capability chips are live: flip one and the matching observation mode in every figure below is greyed out.
Text version: the Analyst’s Agent Card and what each field does

Served at https://analyst.example/.well-known/agent-card.json.

{
  "name": "Analyst",
  "description": "Answers questions about product usage, churn and revenue from the warehouse and the billing system.",
  "supportedInterfaces": [
    {
      "url": "https://analyst.example/a2a",
      "protocolBinding": "JSONRPC",
      "protocolVersion": "1.0"
    }
  ],
  "provider": {
    "url": "https://analyst.example",
    "organization": "Data Platform Team"
  },
  "version": "2.3.0",
  "capabilities": {
    "streaming": true,
    "pushNotifications": true,
    "extendedAgentCard": false
  },
  "securitySchemes": {
    "bearer": {
      "httpAuthSecurityScheme": {
        "scheme": "bearer"
      }
    }
  },
  "securityRequirements": [
    {
      "schemes": {
        "bearer": {
          "list": []
        }
      }
    }
  ],
  "defaultInputModes": [
    "text/plain"
  ],
  "defaultOutputModes": [
    "text/plain",
    "text/csv"
  ],
  "skills": [
    {
      "id": "churn",
      "name": "Churn analysis",
      "description": "Churn rates by month, region, cohort or plan.",
      "tags": [
        "analytics",
        "churn"
      ]
    },
    {
      "id": "revenue",
      "name": "Revenue joins",
      "description": "Joins usage metrics with billing revenue. Requires your consent for billing access.",
      "tags": [
        "analytics",
        "billing"
      ]
    },
    {
      "id": "export",
      "name": "Aggregate exports",
      "description": "CSV exports of aggregates. Never exports PII.",
      "tags": [
        "export"
      ]
    }
  ]
}
FieldTypeMeaning
name, description, providerstring / objectIdentity. Who this agent is and who runs it.
supportedInterfaces[]arrayWhere and how to talk to the agent: a url, a protocolBinding (JSONRPC, GRPC or HTTP+JSON) and the protocolVersion it speaks.
capabilities.streamingbooleanWhether SendStreamingMessage and SubscribeToTask are offered.
capabilities.pushNotificationsbooleanWhether the agent will POST task updates to a webhook you register.
capabilities.extendedAgentCardbooleanWhether an authenticated, more detailed card exists.
securitySchemes, securityRequirementsmap / arrayHow the client authenticates to the agent. OpenAPI-style scheme definitions plus which are required.
skills[]arrayWhat the agent can do, in prose and tags, with optional examples and input/output modes.

The card says who the agent is, where to reach it, how to authenticate to it, and what it can do in prose. For this post the only fields that change anything are capabilities.streaming and capabilities.pushNotifications. Flip them off and the Analyst becomes an agent you can only poll; the state machine is unchanged, but two of its observation modes go away. Everything else about discovery (well-known URIs vs registries vs direct configuration, signed cards, authenticated extended cards) deserves its own post and may get one in the future.

An important distinction that can create confusion later: securitySchemes on the Agent Card is about your agent authenticating to the Analyst. The auth-required state we’ll meet in scenario 3 is about the Analyst needing your permission to act somewhere else. Different problem, different layer. If you’re familiar with tool-level auth in MCP, this is a very similar distinction.

Message or Task

In A2A, the client calls SendMessage, and the server returns either a Message or a Task. That choice is the server’s.

The Analyst is
You send
SendMessage returns a Task

Hybrid: Uses Messages to negotiate scope, then a Task to track execution.

Real work with a deliverable. This is what a Task is for.

The first fork. A Task exists only if the agent decides to create one. Everything in the rest of this post is downstream of the right-hand branch.
Text version: what SendMessage returns for each prompt and agent archetype
ArchetypeRule
Message-onlyAlways answers with a Message. Uses contextId for continuity, never creates a Task.
Task-generatingAlways creates a Task, even for trivial exchanges. Simple, and every reply is a completed Task.
HybridUses Messages to negotiate scope, then a Task to track execution.
PromptMessage-onlyTask-generatingHybridWhy
What data do you have access to?MessageTaskMessageA question with an immediate answer. No work to track.
Can you do cohort analysis?MessageTaskMessageScope negotiation. A hybrid agent answers in a Message and waits for the real request.
Chart last quarter’s churn by month.MessageTaskTaskReal work with a deliverable. This is what a Task is for.
Backfill churn for the last five years.MessageTaskTaskLong-running. Without a Task there is nothing to poll, cancel or resume.

The spec describes three archetypes. A message-only agent never creates tasks and relies on contextId for continuity, which is fine for a chatbot. A task-generating agent creates a task for everything, even “what data do you have?”, and ends up with a lot of trivially completed tasks. A hybrid agent uses messages to negotiate scope and tasks to track execution. The Analyst is a hybrid, and I think most useful agents will be.

The point of the fork is that a task exists because the agent decided it should. A Message means “here is your answer, nothing to track.” A Task means “this will take a while, or might need you, or might produce things; here is a handle.” Everything from here on happens on the right-hand branch.

Observing a task

Before the state machine itself, one piece of vocabulary. Nothing about a task’s lifecycle depends on how your agent finds out about each transition. There are three ways, and the Agent Card determines which ones are supported by the agent:

  • Polling. GetTask with the task’s id, and optionally historyLength to trim the message history. You see whatever state the task is in when you ask. You see artifacts only as part of the snapshot, so intermediate chunks are invisible.
  • Streaming. SendStreamingMessage opens a stream that first returns the Task, then emits TaskStatusUpdateEvent and TaskArtifactUpdateEvent as they happen. When the task pauses in an interrupted state the stream ends; after you reply, your reply’s own stream picks up. SubscribeToTask attaches a stream to an existing task, for reconnects.
  • Push. Your agent registers a webhook with CreateTaskPushNotificationConfig, and the Analyst POSTs events to it. This is how your agent finds out about auth-required without polling.

In each figure below, the “observe by” toggle changes the grey note under each event: what the client saw, and when. The states and transitions never change. If you flipped streaming or pushNotifications off in the Agent Card above, those modes are greyed out here, which is exactly what would happen to your agent.

Text version: how each step of scenario 2 is observed by polling, streaming and push
#ActorEventStatePolling GetTaskStreamingPush notifications
1agentSendMessage returns a TasksubmittedReturned inline by SendMessageFirst event on the streamReturned inline by SendMessage
2agentstarts workworkingSeen on GetTask #1TaskStatusUpdateEventWebhook POST
3agentasks a questioninput-requiredSeen on GetTask #2TaskStatusUpdateEvent. The stream ends here; the task is waiting.Webhook POST. Nothing more arrives until the client acts.
4clientSendMessage (same taskId)workingThe client’s own request. The response is the updated Task.SendStreamingMessage opens a new stream for the same taskSendMessage; the webhook keeps receiving updates
5agentfinishescompletedSeen on GetTask #3TaskArtifactUpdateEvent then TaskStatusUpdateEvent (final)Two webhook POSTs

The state sequence is identical in all three columns. Only when and how the client learns about each step changes. If the Agent Card says streaming: false or pushNotifications: false, that column is unavailable and the client must poll.

The state machine

A task is in exactly one of these states at any moment:

  • In flight: submitted, working.
  • Interrupted, resumable: input-required, auth-required.
  • Terminal, closed forever: completed, failed, canceled, rejected.

(The enum also has an unspecified value for unknown states. It should never appear on a healthy wire, so I’m leaving it out of the graph.)

Every figure in this section is the same machine. Solid edges are moves the agent makes; dashed edges are moves the client makes. Each scenario steps through its own path: press “next step” to send the next event, and the graph shows you where it is about to go before it goes. The event log on the right keeps the TaskStatus snapshot behind every row.

In the graph
A transition the agent makes (starts work, asks, finishes, fails, declines).
A transition the client makes (replies, authorizes, cancels).
A transition this task has already taken.
The transition the next step will take.
state The state the task is in right now.
state A state the task passed through earlier.
state Where the next step goes. In free play, shaded states are the legal moves.
state Rectangles are terminal states; rounded states can still move.
In the event log
A The Analyst (the agent) caused this event.
C Your agent (the client) caused this event.
working completed The state after the event. Filled black badges are terminal.
Controls
Reset the scenario to before the first message.
Go back one step.
Send the next event in the scenario.
Text version: every state and every legal transition of the machine

States

StateEnumGroupMeaning
submittedTASK_STATE_SUBMITTEDin-flightAcknowledged by the agent. Nothing has happened yet.
workingTASK_STATE_WORKINGin-flightThe agent is actively processing the task.
input-requiredTASK_STATE_INPUT_REQUIREDinterruptedThe agent is waiting for the client to send more information. Resumable.
auth-requiredTASK_STATE_AUTH_REQUIREDinterruptedThe agent needs the client to complete an authorization step before it can continue. Resumable.
completedTASK_STATE_COMPLETEDterminalFinished successfully. Artifacts are final. Closed forever.
failedTASK_STATE_FAILEDterminalThe agent tried and could not finish. Closed forever.
canceledTASK_STATE_CANCELEDterminalStopped at the client’s request before finishing. Closed forever.
rejectedTASK_STATE_REJECTEDterminalThe agent declined to do the work at all. Closed forever.

Legal transitions

FromEventActorToNote
submittedstarts workagentworkingThe agent picks the task up.
submitteddeclinesagentrejectedThe agent refuses before doing anything. status.message explains why.
submittedCancelTaskclientcanceledThe client withdraws the task before the agent starts.
workingfinishesagentcompletedFinal artifacts are attached.
workinghits an erroragentfailedstatus.message carries the failure.
workingCancelTaskclientcanceledCooperative. The agent may refuse with TaskNotCancelableError.
workingasks a questionagentinput-requiredThe question travels in status.message.
workingneeds permissionagentauth-requiredstatus.message says which credential or consent is missing.
workingdeclinesagentrejectedThe agent can still decline after starting, e.g. once it sees what the data is.
input-requiredSendMessage (same taskId)clientworkingThe answer is a normal Message carrying the task’s id. The task resumes.
input-requiredCancelTaskclientcanceledThe client gives up instead of answering.
auth-requiredSendMessage after authclientworkingThe client completes the auth flow out of band, then sends a Message with the task’s id.
auth-requiredCancelTaskclientcanceledThe client declines to authorize.
auth-requiredgives upagentfailedThe agent times out waiting for authorization.

Illegal transitions (selection)

  • submitted → input-required: The agent asks for input or auth only once it is working on the task.
  • submitted → auth-required: The agent asks for input or auth only once it is working on the task.
  • submitted → completed: A task that never started cannot finish or fail. It goes through working, or it gets rejected.
  • submitted → failed: A task that never started cannot finish or fail. It goes through working, or it gets rejected.
  • working → submitted: Nothing moves a task back to submitted. That state exists only at creation.
  • input-required → submitted: Nothing moves a task back to submitted. That state exists only at creation.
  • input-required → auth-required: An interrupted task resumes to working first. The agent decides what it needs next from there.
  • input-required → completed: No transition from input-required to completed is defined.
  • input-required → failed: No transition from input-required to failed is defined.
  • input-required → rejected: The agent is waiting on the client here; a decline would come after the client answers and the task is working again.
  • auth-required → submitted: Nothing moves a task back to submitted. That state exists only at creation.
  • auth-required → input-required: An interrupted task resumes to working first. The agent decides what it needs next from there.
  • auth-required → completed: No transition from auth-required to completed is defined.
  • auth-required → rejected: No transition from auth-required to rejected is defined.
  • completed → working: completed is terminal. Once a task reaches a terminal state it cannot restart. A refinement is a new task in the same context.
  • failed → working: failed is terminal. Once a task reaches a terminal state it cannot restart. A refinement is a new task in the same context.
  • canceled → working: canceled is terminal. Once a task reaches a terminal state it cannot restart. A refinement is a new task in the same context.
  • rejected → working: rejected is terminal. Once a task reaches a terminal state it cannot restart. A refinement is a new task in the same context.

Scenario 1: Happy path

“Chart last quarter’s churn by month.”  The Analyst returns a Task in submitted, moves to working, and finishes in completed. Already one thing MCP tasks cannot do: the table arrives as one artifact chunk and the chart arrives as a second chunk appended to it, both while the task is still working. An MCP task would hand you both at completed and nothing before.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 1. Step through the happy path. Switch the observation mode to see that polling never notices the two artifact chunks.
Text version: scenario 1 step by step, with how each step is observed in every mode

Prompt: “Chart last quarter’s churn by month.”. The analyst accepts, works, streams a table and then a chart, and completes.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inline by SendMessageFirst event on the stream: the Task objectReturned inline by SendMessage
2agentstarts workworkingPulling churn events from the warehouse.Seen on GetTask #1TaskStatusUpdateEventWebhook POST with TaskStatusUpdateEvent
3agentemits artifact chunkworkingNot observable. Polling sees artifacts only in the Task snapshot.TaskArtifactUpdateEvent (append=false, lastChunk=false)Webhook POST with TaskArtifactUpdateEventNo equivalent. An MCP task has one result at the end.
4agentemits artifact chunkworkingNot observable until the next GetTaskTaskArtifactUpdateEvent (append=true, lastChunk=true)Webhook POST with TaskArtifactUpdateEvent
5agentfinishescompletedChurn fell every month of the quarter.Seen on GetTask #2, with artifacts attachedTaskStatusUpdateEvent (final). Stream closes.Webhook POST with TaskStatusUpdateEvent (final)

MCP Tasks verdict: Works as an MCP task. But the table and the chart arrive together, once, at completed. MCP Tasks has no partial results.

Notice TaskStatus.timestamp on every row. The status object is small on purpose: a state, a time, and optionally a message. The message is where the agent talks about the state, as opposed to producing output.

Scenario 2: The agent asks back

“Compare churn across regions.”  The Analyst has two candidate groupings and two candidate granularities, and it would rather ask than guess. It moves to input-required and puts the question in status.message.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 2. The task pauses in input-required. The client's reply is an ordinary SendMessage carrying the same taskId.
Text version: scenario 2 step by step, with how each step is observed in every mode

Prompt: “Compare churn across regions.”. The analyst needs to know which regions and what granularity. It pauses, the client answers with the same taskId, work resumes.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inline by SendMessageFirst event on the streamReturned inline by SendMessage
2agentstarts workworkingChecking which region dimensions exist.Seen on GetTask #1TaskStatusUpdateEventWebhook POST
3agentasks a questioninput-requiredI have sales regions (5) and billing countries (31). Which grouping, and monthly or weekly?Seen on GetTask #2TaskStatusUpdateEvent. The stream ends here; the task is waiting.Webhook POST. Nothing more arrives until the client acts.status input_required, with an inputRequests map holding an elicitation form.
4clientSendMessage (same taskId)workingSales regions, monthly.The client’s own request. The response is the updated Task.SendStreamingMessage opens a new stream for the same taskSendMessage; the webhook keeps receiving updatestasks/update with inputResponses keyed to the elicitation.
5agentfinishescompletedEMEA churns twice as fast as the rest. Chart attached.Seen on GetTask #3TaskArtifactUpdateEvent then TaskStatusUpdateEvent (final)Two webhook POSTs

MCP Tasks verdict: MCP Tasks has input_required too. The difference is the reply: a structured inputResponses payload via tasks/update, keyed to an elicitation the server defined. In A2A the reply is a plain Message with parts, so the client can answer in prose, attach a file, or say something the agent did not anticipate.

Unlike MCP, your agent does not call a special method to reply. It calls SendMessage again with an ordinary Message whose taskId is the paused task’s id. That is the entire resumption protocol. The task goes back to working and finishes.

This is where A2A and MCP tasks are most different. MCP has input_required too, and it is answered with tasks/update, carrying inputResponses keyed to an inputRequests map the server defined, typically an elicitation form. It is a structured, server-shaped reply. A2A’s reply is a message with parts: prose, a file, structured data, or something the agent did not anticipate at all. One is a form. The other is a turn in a conversation. Neither is wrong, and the difference tells you what each protocol thinks a task is. More importantly, an A2A input-required is usually meant for the agent to resolve, while MCP elicitations are mostly meant for the operator to resolve.

Scenario 3: The agent needs your permission

“Add billing revenue to that chart.” Revenue lives in the billing system, the Analyst can’t read it as you without your consent, and so it stops in auth-required with a consent URL in status.message. You authorize out of band. Your agent sends a Message with the same taskId saying go ahead, and the task resumes.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 3. Same shape as scenario 2, different state. Try the push mode: it is the only way to learn about auth-required without polling.
Text version: scenario 3 step by step, with how each step is observed in every mode

Prompt: “Add billing revenue to that chart.”. Revenue lives in the billing system, and the analyst cannot read it on your behalf without your consent. It pauses in auth-required. You authorize, and it resumes.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inlineFirst event on the streamReturned inline
2agentstarts workworkingJoining churn with billing revenue.Seen on GetTask #1TaskStatusUpdateEventWebhook POST
3agentneeds permissionauth-requiredI need read access to the billing system on your behalf. Authorize at https://billing.example/consent?req=7f3cSeen on GetTask #2TaskStatusUpdateEvent. Stream ends; the task is waiting.Webhook POST. The webhook is the only way to learn this without polling.input_required with a URL-mode elicitation carrying the consent URL. Same dance; the state just does not say it is about auth.
4clientSendMessage after authworkingAuthorized. Go ahead.Client request; response is the updated TaskNew SendStreamingMessage for the same taskSendMessage
5agentfinishescompletedRevenue and churn move in opposite directions, as hoped.Seen on GetTask #3TaskArtifactUpdateEvent then TaskStatusUpdateEvent (final)Two webhook POSTs

MCP Tasks verdict: URL-mode elicitation covers the mechanism: the task pauses in input_required, the elicitation carries a consent URL, and the flow completes out of band, with credentials the client never sees. What MCP lacks is the named state; a wait for consent looks like any other wait for input unless the client inspects the request.

With MCP, you would need URL-mode elicitation to support this: the task pauses in input_required, the elicitation carries a consent URL, the user authorizes out of band, and the server resumes with credentials the client never sees. The MCP spec is even stricter than A2A’s prose about that last boundary. What A2A adds is smaller: the wait for consent is a named state of the task. Your agent, a dashboard, or a policy engine can see auth-required and treat it differently from input-required (page a human, apply a longer timeout, escalate) without opening the input request to guess what kind of wait this is. MCP can express the situation; A2A can talk about it.

Scenario 4: The agent says no

“Export every customer’s email address.”  The Analyst declines, in submitted, before doing anything at all, and moves to rejected. status.message says why and offers an alternative.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 4. Two steps and done. The task never reaches working.
Text version: scenario 4 step by step, with how each step is observed in every mode

Prompt: “Export every customer’s email address.”. Policy forbids bulk PII export. The analyst declines before doing anything, and says why in status.message.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inlineFirst event on the streamReturned inline
2agentdeclinesrejectedBulk export of customer PII is not something I do. I can produce aggregate counts per region if that helps.Often already visible in the SendMessage response itselfTaskStatusUpdateEvent (final). Stream closes.Webhook POST (final)an error code, or refusal text in an otherwise successful result. Nothing standard marks it as a final refusal.

MCP Tasks verdict: Refusal is expressible: an error code, or a successful result whose text politely says no. It is just not standardized, so a generic client cannot separate “I won’t” from “I couldn’t” without server-specific convention. Retry logic is where that bites.

rejected and failed are both terminal states and both mean you are not getting what you asked for. They differ in intent. failed is “I tried and could not.” rejected is “I refuse.” Can MCP express a refusal? Of course: an error code, or a perfectly successful result whose text says no, which agents behind tools do constantly. What MCP lacks is a standard way to handle this, so every server picks its own, and a generic client cannot tell refusal from failure without server-specific knowledge. A2A’s version is optimized for clear retry logic: failed is often worth retrying, rejected never is, and an orchestrator can only apply that rule if the protocol states which one happened.

Scenario 5: Something breaks

“Backfill churn for the last five years.”  The warehouse query hits its limit and the Analyst moves to failed, with the error and a suggestion in status.message. Nothing here is specific to A2A. Both protocols do this the same way.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 5. In polling mode, note how many GetTask calls returned working before the failure showed up.
Text version: scenario 5 step by step, with how each step is observed in every mode

Prompt: “Backfill churn for the last five years.”. The warehouse query times out. The analyst reports the failure and the task closes.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inlineFirst event on the streamReturned inline
2agentstarts workworkingScanning 60 months of churn events. This will take a while.Seen on GetTask #1TaskStatusUpdateEventWebhook POST
3agenthits an errorfailedWarehouse query exceeded the 10 minute limit. Try a shorter window or a pre-aggregated table.Seen on GetTask #4 (three polls returned working)TaskStatusUpdateEvent (final). Stream closes.Webhook POST (final)

MCP Tasks verdict: Same in both. failed with an error payload.

Scenario 6: You change your mind

Same backfill, but partway through you don’t want it anymore. Your agent calls CancelTask. In the main path the Analyst honors it and the task ends in canceled.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 6. The client's CancelTask is the dashed edge. The agent honors it.
Text version: scenario 6 step by step, with how each step is observed in every mode

Prompt: “Backfill churn for the last five years. (Then: never mind.)”. Same backfill, but the client cancels. In the variant the agent refuses because it is already finishing.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inlineFirst event on the streamReturned inline
2agentstarts workworkingScanning 60 months of churn events.Seen on GetTask #1TaskStatusUpdateEventWebhook POST
3clientCancelTaskcanceledCanceled at the client’s request.The CancelTask response is the updated TaskTaskStatusUpdateEvent (final) on the open stream, plus the CancelTask responseWebhook POST (final)

MCP Tasks verdict: Same in both: cancellation is cooperative. MCP acknowledges the request and may still end in a non-cancelled state; A2A returns TaskNotCancelableError when it refuses.

The variant: the Analyst is already writing results and refuses with TaskNotCancelableError. The task stays working, and it finishes on its own.

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Press ▶ to send the first message.
next A SendMessage returns a Tasksubmitted
Scenario 6b. Same request to cancel, refused. Notice the state does not move on that step.
Text version: scenario 6b step by step, with how each step is observed in every mode

Prompt: “Backfill churn for the last five years. (Then: never mind.)”. Variant: the agent refuses to cancel.

#ActorEventStateMessagePolling GetTaskStreamingPushMCP Tasks
1agentSendMessage returns a TasksubmittedReturned inlineFirst event on the streamReturned inline
2agentstarts workworkingScanning 60 months of churn events.Seen on GetTask #1TaskStatusUpdateEventWebhook POST
3clientCancelTask, refusedworkingTaskNotCancelableError: results are being written.CancelTask returns the error; GetTask still says workingCancelTask returns the error; the stream stays openCancelTask returns the error
4agentfinishescompletedBackfill complete. 60 rows.Seen on GetTask #2TaskStatusUpdateEvent (final)Webhook POST (final)

MCP Tasks verdict: Same in both: cancellation is cooperative. MCP acknowledges the request and may still end in a non-cancelled state; A2A returns TaskNotCancelableError when it refuses.

Cancellation is cooperative in both protocols. The client asks, and the agent (or server) decides. A2A is explicit about the refusal with a named error; MCP acknowledges the request and may still finish in a state other than cancelled.

The rule of terminal states

This last figure is not scripted. Click submitted to start, then click any shaded state to walk the graph. Walk it to completed, and then try to click working. The graph won’t let you: once a task reaches a terminal state it cannot restart. Not for a refinement, not for a retry, not because the user said “actually, make it red.” Whatever comes next is a new task. Which raises the question of how new tasks relate to old ones. Let’s have a look!

Observe by
in flightinterrupted · resumableterminal · closed foreveragent: starts workagent: declinesclient: CancelTaskagent: finishesagent: hits an errorclient: CancelTaskagent: asks a questionagent: needs permissionagent: declinesclient: SendMessage (same taskId)client: CancelTaskclient: SendMessage after authclient: CancelTaskagent: gives upsubmittedworkinginput-requiredauth-requiredcompletedfailedcanceledrejected
Event logno task yet
  1. Click submitted to start a task by hand.
free play click a shaded state to move the task
Free play. Every legal transition is available; every illegal one explains itself.
Text version: free play. Every legal transition is listed in the state machine table above.
FromEventActorToNote
submittedstarts workagentworkingThe agent picks the task up.
submitteddeclinesagentrejectedThe agent refuses before doing anything. status.message explains why.
submittedCancelTaskclientcanceledThe client withdraws the task before the agent starts.
workingfinishesagentcompletedFinal artifacts are attached.
workinghits an erroragentfailedstatus.message carries the failure.
workingCancelTaskclientcanceledCooperative. The agent may refuse with TaskNotCancelableError.
workingasks a questionagentinput-requiredThe question travels in status.message.
workingneeds permissionagentauth-requiredstatus.message says which credential or consent is missing.
workingdeclinesagentrejectedThe agent can still decline after starting, e.g. once it sees what the data is.
input-requiredSendMessage (same taskId)clientworkingThe answer is a normal Message carrying the task’s id. The task resumes.
input-requiredCancelTaskclientcanceledThe client gives up instead of answering.
auth-requiredSendMessage after authclientworkingThe client completes the auth flow out of band, then sends a Message with the task’s id.
auth-requiredCancelTaskclientcanceledThe client declines to authorize.
auth-requiredgives upagentfailedThe agent times out waiting for authorization.

Contexts, follow-ups, parallel tasks

Back to the rule. Task 1 charted churn by month and is completed. You want the same chart split by signup cohort. You cannot reopen task 1. What you do instead is send a new Message in the same contextId, with referenceTaskIds: ["task-01"] so the Analyst knows what you are refining, and the Analyst creates task 2.

Send a follow-up
contextId: ctx-3e8a
  • task-01 completed
    Chart last quarter’s churn by month.
    artifact churn-q2art-9f1

Last request

// send a follow-up
Each follow-up creates a new task beside the old one. task-01 is completed and stays that way; the refinement points back to it with referenceTaskIds, keeps the artifact name churn-q2, and mints a new artifactId. Two follow-ups can be in flight at once under one context.
Text version: one context, four tasks, and the follow-up requests that created them

contextId: ctx-3e8a

TaskPromptFinal statereferenceTaskIdsArtifacts (name → id)
task-01Chart last quarter’s churn by month.completed(none)churn-q2 → art-9f1
task-02Now split it by signup cohort.completedtask-01churn-q2 → art-9f2
task-03Also compute LTV per region.completed(none)ltv-by-region → art-9f3
task-04Make the cohort chart a heatmap.completedtask-02churn-q2 → art-9f4

task-02 and task-04 refine task-01 and task-02 respectively: same artifact name churn-q2, new artifactId each time. task-03 is independent work in the same context and can run in parallel with task-02. task-01 is never reopened.

Requests

{
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "msg-10",
      "role": "ROLE_USER",
      "contextId": "ctx-3e8a",
      "referenceTaskIds": [
        "task-01"
      ],
      "parts": [
        {
          "text": "Now split it by signup cohort."
        }
      ]
    }
  }
}

{
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "msg-11",
      "role": "ROLE_USER",
      "contextId": "ctx-3e8a",
      "referenceTaskIds": [],
      "parts": [
        {
          "text": "Also compute LTV per region."
        }
      ]
    }
  }
}

{
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "msg-12",
      "role": "ROLE_USER",
      "contextId": "ctx-3e8a",
      "referenceTaskIds": [
        "task-02"
      ],
      "parts": [
        {
          "text": "Make the cohort chart a heatmap."
        }
      ]
    }
  }
}

Three things to notice while you click:

  1. The old task never changes. It is completed, its artifacts are final, and it stays in the tree as a record of what was done.
  2. Refinements keep the artifact name and mint a new id. The spec’s position is that the client is best placed to manage artifact versions, so the agent’s job is to keep name stable (churn-q2) and give each version its own artifactId. Your agent decides which version is current.
  3. Parallel work is just two follow-ups. “Also compute LTV per region” has nothing to do with the churn chart, so it references nothing, and it can run while task 2 is still working. The context is what holds them together.

Let’s compare this to what MCP’s task handle does in similar scenarios. The handle relates every operation on one unit of work. Polling, mid-flight input, cancellation and the final result all reference the same taskId, and that is the point of the handle. It does not relate one unit of work to the next. A refinement is a fresh tools/call producing a fresh task, and its connection to the previous one lives in the prompt your agent wrote, invisible to the protocol. A2A’s contextId and referenceTaskIds are the layer above the handle that MCP can’t see. In my opinion, agents that require this kind of context tracking are the strongest argument for A2A.

Artifacts

If you’ve paid attention above, you’ve noticed multiple references to artifacts, and how I use “artifact” and “result” interchangeably. Artifacts arrive as TaskArtifactUpdateEvents, each carrying a chunk and two flags. append says whether this chunk extends the artifact or replaces it. lastChunk says whether the artifact is now complete.

append on later chunks:
#1 art-9f1 · append=false · lastChunk=false#2 art-9f1 · append=true · lastChunk=false#3 art-9f1 · append=true · lastChunk=true

Assembled artifact churn-q2

// nothing yet

Last event

// press next
With append: true the artifact grows; with append: false the chunk replaces what was there. lastChunk: true tells the client the artifact is complete. The agent chooses per event.
Text version: the artifact chunks of scenario 1 and how they assemble
#artifactIdnameappendlastChunktext
1art-9f1churn-q2falsefalsemonth,churn⏎ 2026-04,3.1%⏎ 2026-05,2.8%⏎ 2026-06,2.4%
2art-9f1churn-q2truetrue⏎ ⏎ [chart: churn-q2.svg]

Assembled with append as sent: chunk 1 sets the content, chunk 2 appends, lastChunk closes the artifact. If chunk 2 had append: false it would replace chunk 1 entirely. The agent chooses per event.

month,churn
2026-04,3.1%
2026-05,2.8%
2026-06,2.4%

[chart: churn-q2.svg]

The agent chooses per event. A streaming text response is a run of append: true chunks with a final lastChunk: true. A progress table that keeps getting redrawn is a run of append: false chunks. Both are legal, and the client has to handle both. This is also the mechanism behind “the chart arrived before the task completed” in scenario 1, and it has no equivalent in MCP tasks.

Back to MCP

Seven scenarios. For each, would an MCP task have done the job?

#ScenarioWould an MCP task do?Why
1 Happy path Yes, with a caveat Works as an MCP task. But the table and the chart arrive together, once, at completed. MCP Tasks has no partial results.
2 The agent asks back Yes, with a caveat MCP Tasks has input_required too. The difference is the reply: a structured inputResponses payload via tasks/update, keyed to an elicitation the server defined. In A2A the reply is a plain Message with parts, so the client can answer in prose, attach a file, or say something the agent did not anticipate.
3 The agent needs your permission Yes, with a caveat URL-mode elicitation covers the mechanism: the task pauses in input_required, the elicitation carries a consent URL, and the flow completes out of band, with credentials the client never sees. What MCP lacks is the named state; a wait for consent looks like any other wait for input unless the client inspects the request.
4 The agent says no Yes, with a caveat Refusal is expressible: an error code, or a successful result whose text politely says no. It is just not standardized, so a generic client cannot separate “I won’t” from “I couldn’t” without server-specific convention. Retry logic is where that bites.
5 Something breaks Yes Same in both. failed with an error payload.
6 You change your mind Yes Same in both: cancellation is cooperative. MCP acknowledges the request and may still end in a non-cancelled state; A2A returns TaskNotCancelableError when it refuses.
7 Contexts, follow-ups, parallel tasks No The MCP task handle relates every operation on one unit of work, and nothing beyond it. There is no context grouping and no reference between tasks; a refinement is a fresh call whose connection to the last one lives in your prompt.

Two yes, four with a caveat, one no. Read down the caveats and a pattern falls out: almost everything A2A’s task does, MCP can express by convention. Pausing for consent is input_required plus a URL elicitation. Refusing is an error code or a polite no in the result. The problem with soft conventions is that you cannot guarantee that every server and client implements them correctly.

These patterns should also tell you what to choose. If your agent needs one result from another agent, with no back-and-forth, no permission it doesn’t already have, and no relationship to the last thing it asked, then an MCP tool in front of that agent is simpler and you should use it. MCP tasks make that true even when the result takes a while. A2A earns its place the moment the work becomes a thread: when the other side might ask, might need your permission, might decline, might send drafts, and when what you ask next depends on what it did last.

If you can only learn one, I’d pick MCP. In any case, if you’re serious about agentic patterns I encourage you to learn A2A as well, even if you don’t end up adding support for it to your agents.

Where the spec is going

A2A is at v1.0 with the proto file as the normative source, which is a recent shift and the reason the enum names in this post look the way they do. The discovery story still has an open item (there is no standard registry API yet).

If you spot a place where I’ve misread the spec, tell me. The state machine on this page is generated from one data file, and I’d rather fix the file than argue.

← Back to Archive