Specifications describe objects one at a time; what most people need when they first build an Agent2Agent integration is to see whole conversations on the wire. This article follows one scenario, an expense assistant that delegates receipt processing to a remote receipts agent, through six exchanges that together cover nearly everything A2A 1.0 does: a quick answer, a completed task with structured output, a task that pauses for input, a streamed task, a long job reported by push notification, and cancellation, including the error you get when you are too late.

Every JSON shape here was checked against the A2A 1.0 specification and its normative protobuf definition, as tagged v1.0.0 in the a2aproject repository, on 2026-10-01. If you learned A2A from 0.3-era tutorials, expect differences: methods are PascalCase such as SendMessage rather than slash-separated names, parts no longer carry a kind field, and enum values are written like TASK_STATE_COMPLETED. Long identifiers are shortened with an ellipsis for readability. For the objects themselves, see the Message article and the Task article.

Advertisement

The scenario and discovery

The client is an expense assistant that people chat with. The server is a receipts agent run by another team, which reads receipt images and returns fields such as merchant, date, total and tax. Before anything else the client fetches the agent card from the well-known path. The card tells it which protocol bindings exist and at which URLs, whether streaming and push notifications are supported, which media types are accepted, and what skills the agent offers.

GET /.well-known/agent-card.json HTTP/1.1
Host: receipts.example.com

{
  "name": "Receipts agent",
  "description": "Extracts merchant, date, totals and tax from receipt images and PDFs.",
  "version": "2.3.0",
  "supportedInterfaces": [
    {"url": "https://receipts.example.com/a2a/jsonrpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"},
    {"url": "https://receipts.example.com/a2a/rest", "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0"}
  ],
  "capabilities": {"streaming": true, "pushNotifications": true},
  "defaultInputModes": ["image/jpeg", "image/png", "application/pdf", "text/plain"],
  "defaultOutputModes": ["application/json", "text/plain"],
  "skills": [{
    "id": "extract-receipt",
    "name": "Extract receipt",
    "description": "Returns structured fields for one receipt.",
    "tags": ["finance", "ocr", "receipts"],
    "examples": ["Extract this receipt", "What was the VAT on this?"]
  }]
}

Three fields drive client behaviour. supportedInterfaces lists the endpoints; this agent offers JSON-RPC and HTTP+JSON, and the client picks the first one it supports. capabilities gates the optional operations: calling the streaming method on an agent that does not declare streaming must fail with UnsupportedOperationError, so check before you call. The input and output modes tell the client which part types it may send and request. Card fields in detail are covered in the Agent Card specification article.

One client agent, one remote agent, six exchanges over A2A 1.0Expense assistantA2A clientReceipts agentA2A serverGET /.well-known/agent-card.json1. SendMessage, answered by a Message2. SendMessage, answered by a completed Task3. INPUT_REQUIRED, then a follow-up on the same task4. SendStreamingMessage, SSE events5. returnImmediately + push configwebhook POST: StreamResponse6. CancelTask, including on a finished taskWebhook endpointTask storeEvery request carries A2A-Version: 1.0. The same operations exist on HTTP+JSON at /message:send and /tasks/{id}:cancel.
The six exchanges in this article. Each uses the JSON-RPC binding; the last section shows the same calls over HTTP+JSON.

Example 1: a question answered by a Message

Not every interaction needs a task. When the agent can answer immediately and there is nothing to track, it may reply with a Message. Every request carries the A2A-Version header; the specification says a request without it is treated as version 0.3, which produces confusing errors from a 1.0 server.

POST /a2a/jsonrpc HTTP/1.1
Content-Type: application/json
A2A-Version: 1.0
Authorization: Bearer <token>

{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": {
  "message": {"messageId": "9f1c...-01", "role": "ROLE_USER",
              "parts": [{"text": "Which currencies can you read?"}]}}}

HTTP/1.1 200 OK
{"jsonrpc": "2.0", "id": 1, "result": {
  "message": {"messageId": "a77e...-01", "role": "ROLE_AGENT",
              "parts": [{"text": "Any ISO 4217 currency printed on the receipt."}]}}}

The result object holds exactly one of message or task. A client must handle both, because the server decides which one to return. The messageId is generated by the sender and should be unique per message; servers can use it to detect duplicate deliveries.

Advertisement

Example 2: a receipt extracted as a completed Task

Now the client sends a real job: an instruction plus the receipt image. A part holds exactly one of text, raw (bytes, base64-encoded in JSON), url or data (arbitrary JSON), with optional mediaType and filename. The client also says it wants JSON back through acceptedOutputModes.

{"jsonrpc": "2.0", "id": 2, "method": "SendMessage", "params": {
  "message": {"messageId": "9f1c...-02", "role": "ROLE_USER", "parts": [
    {"text": "Extract this receipt"},
    {"raw": "/9j/4AAQSkZJRgABAQ...", "mediaType": "image/jpeg", "filename": "taxi.jpg"}]},
  "configuration": {"acceptedOutputModes": ["application/json"]}}}

{"jsonrpc": "2.0", "id": 2, "result": {"task": {
  "id": "task-5d2e", "contextId": "ctx-81aa",
  "status": {"state": "TASK_STATE_COMPLETED", "timestamp": "2026-10-01T14:20:07Z"},
  "artifacts": [{"artifactId": "art-1", "name": "receipt.json", "parts": [
    {"data": {"merchant": "City Cabs", "date": "2026-09-28", "total": "38.50",
              "currency": "EUR", "tax": "3.50"}, "mediaType": "application/json"}]}]}}}

By default SendMessage is blocking: the server holds the request until the task reaches a terminal state, or an interrupted one such as input required. Here the extraction finished quickly, so the response is a Task in TASK_STATE_COMPLETED with one artifact. The result is a data part, not text, so the client gets fields it can validate against a schema instead of prose it must parse. Note the contextId: it groups related tasks into one conversation, and the client should keep it.

Example 3: input required, then a follow-up

Some receipts are ambiguous. Instead of guessing, the receipts agent pauses the task in TASK_STATE_INPUT_REQUIRED and explains what it needs in the status message. This is not a terminal state: the task is waiting, and the client continues it by sending a new message that names the same taskId and contextId.

// Response to the first message: the agent cannot finish without more input.
{"result": {"task": {"id": "task-77b0", "contextId": "ctx-81aa", "status": {
  "state": "TASK_STATE_INPUT_REQUIRED",
  "message": {"messageId": "a77e...-09", "role": "ROLE_AGENT", "parts": [
    {"text": "Two totals are printed (38.50 and 42.00). Which one was paid?"}]}}}}}

// Follow-up: same task and context, a NEW messageId.
{"jsonrpc": "2.0", "id": 4, "method": "SendMessage", "params": {"message": {
  "messageId": "9f1c...-04", "role": "ROLE_USER",
  "taskId": "task-77b0", "contextId": "ctx-81aa",
  "parts": [{"text": "42.00, it includes the tip."}]}}}

The follow-up gets a fresh messageId; reusing the previous one would look like a duplicate. The client agent should surface the question to its human user, or answer from its own context if it can, and must remember that the task is still open and holding resources on the remote side. Once a task is terminal it cannot be continued: messages to a completed, failed, canceled or rejected task fail with UnsupportedOperationError, and a correction needs a new task in the same context, optionally pointing back with referenceTaskIds.

Example 4: streaming with artifact chunks

For a narrated summary of a batch of receipts, the client wants progress as it happens. It calls SendStreamingMessage with the same parameters as SendMessage and receives Server-Sent Events. In the JSON-RPC binding each event is a JSON-RPC response whose result is a StreamResponse holding exactly one of task, message, statusUpdate or artifactUpdate.

data: {"jsonrpc":"2.0","id":5,"result":{"task":{"id":"task-c3","contextId":"ctx-81aa","status":{"state":"TASK_STATE_SUBMITTED"}}}}

data: {"jsonrpc":"2.0","id":5,"result":{"statusUpdate":{"taskId":"task-c3","contextId":"ctx-81aa","status":{"state":"TASK_STATE_WORKING"}}}}

data: {"jsonrpc":"2.0","id":5,"result":{"artifactUpdate":{"taskId":"task-c3","contextId":"ctx-81aa","artifact":{"artifactId":"summary","parts":[{"text":"Merchant: City Cabs. "}]}}}}

data: {"jsonrpc":"2.0","id":5,"result":{"artifactUpdate":{"taskId":"task-c3","contextId":"ctx-81aa","append":true,"lastChunk":true,"artifact":{"artifactId":"summary","parts":[{"text":"Total 42.00 EUR."}]}}}}

data: {"jsonrpc":"2.0","id":5,"result":{"statusUpdate":{"taskId":"task-c3","contextId":"ctx-81aa","status":{"state":"TASK_STATE_COMPLETED"}}}}

Two fields on artifact updates matter. append set to true means add these parts to the artifact with the same id rather than replacing it, and lastChunk marks the final piece. Status updates carry the new state and nothing else, so the client decides the stream is done when it sees a terminal or interrupted state. The client below implements those rules. Note its last comment: a stream can end because a proxy closed an idle connection, and an ended stream is not a finished task. If the stream ends early, call GetTask or reconnect with SubscribeToTask. More on streams in A2A streaming.

import json, uuid, httpx

TERMINAL = {"TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED"}
PAUSED = {"TASK_STATE_INPUT_REQUIRED", "TASK_STATE_AUTH_REQUIRED"}

def stream(url, token, text):
    body = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": "SendStreamingMessage",
            "params": {"message": {"messageId": str(uuid.uuid4()), "role": "ROLE_USER",
                                   "parts": [{"text": text}]}}}
    headers = {"A2A-Version": "1.0", "Authorization": f"Bearer {token}",
               "Accept": "text/event-stream"}
    artifacts, state, task_id = {}, None, None
    with httpx.stream("POST", url, json=body, headers=headers, timeout=None) as r:
        r.raise_for_status()
        for line in r.iter_lines():
            if not line.startswith("data:"):
                continue                                  # blank separators, comments
            msg = json.loads(line[5:])
            if "error" in msg:
                raise RuntimeError(msg["error"])
            ev = msg["result"]
            if "task" in ev:
                task_id, state = ev["task"]["id"], ev["task"]["status"]["state"]
            elif "statusUpdate" in ev:
                state = ev["statusUpdate"]["status"]["state"]
            elif "artifactUpdate" in ev:
                a = ev["artifactUpdate"]["artifact"]
                parts = artifacts.setdefault(a["artifactId"], [])
                if not ev["artifactUpdate"].get("append"):
                    parts.clear()                         # a non-append update replaces
                parts.extend(a["parts"])
            elif "message" in ev:
                return {"message": ev["message"]}
            if state in TERMINAL or state in PAUSED:
                break
    # The stream can also just end (proxy timeout, network). Never infer success from that.
    return {"task_id": task_id, "state": state, "artifacts": artifacts}

Example 5: a long job reported by push notification

Processing a quarter of receipts can take minutes, and the expense assistant should not hold a connection open that long. It sends the archive by URL, asks for an immediate return, and registers a webhook in the same request.

{"jsonrpc": "2.0", "id": 6, "method": "SendMessage", "params": {
  "message": {"messageId": "9f1c...-06", "role": "ROLE_USER",
              "parts": [{"url": "https://files.example.com/q3-receipts.zip",
                         "mediaType": "application/zip"}]},
  "configuration": {
    "returnImmediately": true,
    "taskPushNotificationConfig": {
      "url": "https://expenses.example.com/a2a/webhook",
      "token": "per-task-random-value",
      "authentication": {"scheme": "Bearer", "credentials": "<token the agent presents>"}}}}}

The response returns at once with a task still in submitted or working state. When the task changes, the receipts agent POSTs to the webhook, and the body is a StreamResponse, the same shape as one streaming event, for example a statusUpdate announcing completion. The authentication block tells the agent how to authenticate to your webhook, and the token is a value you chose that the agent echoes back, so you can reject notifications that do not match the task.

Your webhook must reply 2xx quickly and process idempotently, because the specification allows duplicate deliveries. Treat a notification as a hint: on receipt, call GetTask to read the authoritative state and artifacts, rather than trusting the payload alone. Keep a polling fallback for tasks that have gone quiet, since a webhook outage on your side means missed notifications. The delivery and security details are in A2A push notifications.

Example 6: cancellation, and cancelling too late

The user changes their mind, so the client calls CancelTask with the task id. If the task is still running, the response is the task, normally moving to TASK_STATE_CANCELED. If it already finished, the agent cannot cancel it and returns TaskNotCancelableError, which the JSON-RPC binding maps to code -32002 with a structured ErrorInfo entry.

{"jsonrpc": "2.0", "id": 7, "method": "CancelTask", "params": {"id": "task-5d2e"}}

{"jsonrpc": "2.0", "id": 7, "error": {
  "code": -32002, "message": "Task cannot be canceled",
  "data": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo",
            "reason": "TASK_NOT_CANCELABLE", "domain": "a2a-protocol.org"}]}}

Handle the error by reading the task, not by retrying. A completed task may already have produced side effects you need to reverse in your own domain. The specification also notes that cancel is idempotent, and that a repeated cancel may return TaskNotFoundError, code -32001, once the task has been purged; treat that as done, not as a failure. Server-side cancellation design is covered in A2A task cancellation.

The same calls over HTTP+JSON

The receipts agent also exposes the HTTP+JSON binding. The operations and objects are identical; only the transport changes. Requests go to resource-style paths, the request body is the params object without the JSON-RPC envelope, and errors use HTTP status codes, such as 404 for an unknown task and 409 for a task that cannot be canceled.

POST /a2a/rest/message:send HTTP/1.1
Content-Type: application/a2a+json
A2A-Version: 1.0

{"message": {"messageId": "9f1c...-08", "role": "ROLE_USER",
             "parts": [{"text": "Which currencies can you read?"}]}}

GET  /a2a/rest/tasks/task-5d2e            -> the Task
POST /a2a/rest/tasks/task-5d2e:cancel     -> 409 Conflict if already terminal

Pick one binding per integration and stick to it. Clients that mix bindings for one task end up with two error-handling paths and twice the test surface.

Choosing an interaction style

The four styles trade connection lifetime against moving parts. Blocking SendMessage is the simplest, one request and one answer, but it holds a connection for the whole task and dies at the first proxy or load balancer idle timeout. Streaming gives live progress and early partial results, at the cost of a long-lived SSE connection that intermediaries may buffer or cut, so you still need reconciliation. Polling GetTask after returnImmediately works through any network and needs no inbound access, but adds latency and request volume. Push notifications are the most efficient for long jobs, yet require a webhook the remote agent can reach through your firewall, authentication in both directions and idempotent handling of duplicates.

A practical rule: blocking for tasks that finish in seconds, streaming when a person is watching, and returnImmediately with push plus a slow polling fallback for anything longer.

Failure modes seen in real integrations

  • Missing version header. The server assumes 0.3 and rejects 1.0 shapes, or interprets them oddly. Send A2A-Version: 1.0 on every request.
  • 0.3 shapes in a 1.0 world. Slash method names, kind discriminators, lowercase state strings or a final flag on status events all come from older tutorials and fail validation.
  • Treating input required as failure. The task waits forever on the remote side and the user never sees the question. Route paused states to a person or a policy.
  • Inferring success from a closed stream. Always confirm the terminal state with GetTask after an unexpected disconnect.
  • Non-idempotent webhooks. Duplicate notifications book the expense twice. Key processing on task id and state.
  • Blocking calls on slow tasks. The default blocking SendMessage meets a 60-second load balancer timeout. Use returnImmediately with push or polling for anything that might be slow.

What to do next

  1. Fetch your target agent card and record the binding, protocol version, capabilities and accepted media types.
  2. Write a client that sends A2A-Version: 1.0, generates a fresh messageId per message and handles both Message and Task results.
  3. Implement the state rules once: terminal states end work, input-required and auth-required pause it, everything else is in progress.
  4. Add streaming with the append and lastChunk rules, plus GetTask reconciliation after any early disconnect.
  5. For slow skills, use returnImmediately with a webhook that checks the token, replies 2xx fast, processes idempotently and reconciles with GetTask.
  6. Handle TaskNotCancelableError and TaskNotFoundError on cancel as outcomes, not crashes.
  7. Capture these transcripts from your own integration as contract tests, and rerun them whenever either side upgrades.
Key takeaway: A2A 1.0 conversations are built from a few operations used in a few patterns: SendMessage answered by a Message or a Task, multi-turn continuation on the same task and context, SSE streaming of StreamResponse events with append and lastChunk, returnImmediately with push notifications, and cancellation with well-defined errors. Send the version header, use 1.0 shapes, treat paused states as waiting rather than failed, never infer success from a closed stream, and reconcile with GetTask whenever in doubt.