All About the A2A Protocol, Now Part of the Agentic AI Foundation

Agent2Agent is not new. Google shipped it in April 2025, and I wrote an early comparison of it against MCP while the spec was still at 0.x. Almost everything in the 2025 guides, mine included, is now wrong at the field level: the well-known path renamed, the type discriminator came off the wire, the agent card stopped having a URL and grew a list of transports, and the streaming rules stopped being advice and started raising errors. v1.0 froze on March 12, 2026, and today the project moved into the Agentic AI Foundation next to MCP, AGENTS.md, goose, and agentgateway. This is the builder's version: what the spec actually standardizes, what changed underneath the old guides, and the shape of the code you write today.


Four objects, and one of them is not a chat

Strip the marketing and A2A standardizes four things. An Agent Card, a JSON manifest saying who an agent is, what it can do, where to reach it, and how to authenticate. A Task, the unit of work, with an ID, a context ID, a state, a history, and artifacts. A Message, made of parts that are text, raw bytes, a URL, or structured data. And an Artifact, which is what the task produced. Everything else in the specification is transport detail over those four.

The design constraint that produced them is worth stating, because it explains the parts of the protocol that look excessive. A2A assumes the agent on the other end is opaque: you do not get its tools, its memory, its model, or its internal plan. You get a card, a task ID, and the states it passes through. That is the whole difference from MCP, which the A2A spec puts in one sentence of its own appendix: MCP standardizes how an agent uses a tool or resource; A2A standardizes how one agent delegates work to another. In practice the same server does both, and the A2A agent you build will call MCP servers to get the work done.

That boundary is easier to see as one exchange than as a definition. Two agents, built by different teams, running on different substrates, and one task passing between them.

Fig. 1 · one delegation, hop by hop

A planner agent in your harness delegates a route to a vendor agent it has never met. Step through the hops: the dashed one is the only hop that is not A2A.

planner-agent your harness worker on your own runtime A2A client georoute-agent vendor harness someone else's cloud A2A server TASK · CONTEXT ctx-19 task-7c2 MCP: maps.route MCP: traffic.feed GET agent-card.json

the diagram scrolls sideways →

Hops follow the v1.0 worked examples and the streaming example in section 6.2. Tool names are illustrative; what the vendor agent calls inside its own process is exactly what A2A does not describe.

The lineage matters for anyone deciding whether to bet on it. Google published A2A in April 2025 and donated it to the Linux Foundation that June with AWS, Cisco, Microsoft, Salesforce, SAP, and ServiceNow as founding organizations. In August 2025 IBM's Agent Communication Protocol merged into it rather than competing with it. The specification repository now sits at roughly 25,000 stars, the foundation counts more than 150 partner organizations, and today it became an AAIF project alongside the rest of the open agent stack.

Discovery is one file at a fixed path

An A2A agent publishes its card at https://your-domain/.well-known/agent-card.json. If you are working from a 2025 guide, that is already a rename: 0.3 moved it from agent.json on July 30, 2025, and v1.0 registered the new suffix as a well-known URI in the spec's IANA section, next to registrations for the application/a2a+json media type and the A2A-Version and A2A-Extensions headers. Clients can also get a card from a registry or from static configuration, but the well-known path is the one every tool tries first.

The card field that reorganized v1.0 is supportedInterfaces. In 0.3 a card had a url. Now it has an ordered list, each entry naming a protocolBinding (JSONRPC, GRPC, or HTTP+JSON), a url, a protocolVersion, and an optional opaque tenant string the client must echo back on every request. Order is preference: first entry wins, and the client walks the list until it finds a binding it speaks. Since the spec also requires that all three bindings be functionally equivalent, the choice is an operational one, not a feature one.

Here is a card as the Python SDK builds it, taken from the project's own multi-transport sample. Note what the ordered list buys you: the same handler is reachable over gRPC, JSON-RPC, and REST, and each binding is advertised twice, once at 1.0 and once at 0.3, so a client on either spec version finds a door it can open.

from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentProvider, AgentSkill

agent_card = AgentCard(
    name='Sample Agent',
    description='A sample agent to test the stream functionality.',
    provider=AgentProvider(organization='A2A Samples', url='https://example.com'),
    version='1.0.0',
    capabilities=AgentCapabilities(streaming=True, push_notifications=False),
    default_input_modes=['text'],
    default_output_modes=['text', 'task-status'],
    skills=[AgentSkill(
        id='sample_agent', name='Sample Agent', description='Say hi.',
        tags=['sample'], examples=['hi'],
        input_modes=['text'], output_modes=['text', 'task-status'],
    )],
    supported_interfaces=[
        AgentInterface(protocol_binding='GRPC',      protocol_version='1.0', url='127.0.0.1:50051'),
        AgentInterface(protocol_binding='GRPC',      protocol_version='0.3', url='127.0.0.1:50052'),
        AgentInterface(protocol_binding='JSONRPC',   protocol_version='1.0', url='http://127.0.0.1:41241/a2a/jsonrpc'),
        AgentInterface(protocol_binding='JSONRPC',   protocol_version='0.3', url='http://127.0.0.1:41241/a2a/jsonrpc'),
        AgentInterface(protocol_binding='HTTP+JSON', protocol_version='1.0', url='http://127.0.0.1:41241/a2a/rest'),
        AgentInterface(protocol_binding='HTTP+JSON', protocol_version='0.3', url='http://127.0.0.1:41241/a2a/rest'),
    ],
)

Two fields in there are load-bearing and easy to get wrong. capabilities is a promise, not a wish list: advertise pushNotifications: true without wiring a sender and clients get PushNotificationNotSupportedError from a server that told them otherwise. And skills is what a routing agent reads when it decides whether to send you work at all, so the examples array is closer to product copy than to documentation.

Fig. 2 · which endpoint the client actually calls

One card, three interfaces, in the agent's preference order. Pick a client and watch the resolution: it takes the first entry it can speak, ignores the rest, and fails closed when nothing matches.

Interfaces adapted from the sample agent card in the v1.0 spec, with the gRPC entry written in the hostname:port form section 4.4.6 prescribes. Client transport support from the Python SDK compatibility matrix and the JavaScript SDK releases, read August 2026.

Two details in that figure cost people a day each. The first is the header: a client must send A2A-Version: 1.0 on every request, and an absent header means 0.3, not "latest". The second is the failure mode. A 0.3 client hitting a 1.0-only agent gets VersionNotSupportedError, and the fix is on the server: advertise a second AgentInterface with protocolVersion: "0.3" and turn on the SDK's compatibility flag. Version negotiation in A2A is a published list plus a header, not content negotiation.

The task is a state machine, and the client's job depends on which state it is in

A v1.0 task holds one of nine states. Four are terminal (COMPLETED, FAILED, CANCELED, REJECTED), two are interrupted (INPUT_REQUIRED, AUTH_REQUIRED), two are active (SUBMITTED, WORKING), and one exists so a proto enum has a zero value (UNSPECIFIED). Sending a message to a task that already reached a terminal state is not a no-op, it is UnsupportedOperationError. Canceling a terminal task is TaskNotCancelableError. Drive the machine and the error names arrive with it.

Fig. 3 · drive the task lifecycle

Every transition the agent makes emits a TaskStatusUpdateEvent on the stream. Illegal moves are not ignored by the protocol, they are named errors. Try canceling after the task completes.

States, classes, and error names from spec section 4.1.3 and the A2A-specific error table in section 3.3.2.

One default surprises almost everyone building their first client. SendMessage is blocking. Unless you set returnImmediately: true in SendMessageConfiguration, the call does not return until the task reaches a terminal or interrupted state. That is a sane default for a five second answer and a bad one for a twenty minute research job, and it is the single line most likely to be behind "our agent calls time out at the gateway".

Three ways to learn that the task moved

Once a task is running, the protocol gives the client three mechanisms, and the choice is a real engineering decision rather than a preference. Polling with GetTask works everywhere, including from behind a firewall that allows nothing inbound. Streaming opens one connection and receives server-sent events as they happen, gated on capabilities.streaming. Push notifications post to a webhook you registered, gated on capabilities.pushNotifications, and are the only mechanism that survives your client process restarting.

Fig. 4 · poll, stream, or webhook, priced in requests

One task emits the same four lifecycle events in every mode. Change how long the work takes and whether the client's connection survives it, and the cost and the loss both move.

task duration180s
poll interval

Counts are computed from the inputs, not measured: four lifecycle events at t=0, t=1s, t=0.6d, t=d, and one poll every interval until the task ends. Mechanism behavior from spec section 3.5.

The webhook payload is worth knowing before you design for it, because it is the same StreamResponse object the stream carries, sent as plain HTTP JSON regardless of which binding the agent otherwise speaks. Exactly one of four members is set.

POST https://ops.example.com/a2a/hooks/task-7c2
Authorization: Bearer <token from PushNotificationConfig>
Content-Type: application/a2a+json

{ "statusUpdate": { "taskId": "task-7c2", "contextId": "ctx-19",
                    "status": { "state": "TASK_STATE_COMPLETED" } } }

The security requirements on that exchange run in both directions, and they are unusually specific for a protocol spec. The agent must include the credentials from your PushNotificationConfig, should time out in 10 to 30 seconds, should retry with backoff, and should refuse to call private address ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) so that a registered webhook cannot be turned into a request forgery inside someone else's network. Your receiver must answer 2xx, must check the task ID is one you created, and should assume duplicate deliveries.

Writing the server, and the two streaming patterns you must choose between

Here is a complete v1.0 agent in the Python SDK, cut down from the project's own hello world sample. Three pieces: a card, an executor, and routes.

from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from starlette.applications import Starlette

card = AgentCard(
    name='Hello World Agent',
    description='Just a hello world agent',
    version='0.0.1',
    default_input_modes=['text/plain'],
    default_output_modes=['text/plain'],
    capabilities=AgentCapabilities(streaming=True, extended_agent_card=True),
    supported_interfaces=[AgentInterface(
        protocol_binding='JSONRPC',
        url='http://127.0.0.1:9999',
        protocol_version='1.0',
    )],
    skills=[AgentSkill(id='echo_bot', name='Echo Bot', description='...',
                       tags=['a2a', 'echo-example'], examples=['hi'])],
)

handler = DefaultRequestHandler(
    agent_executor=HelloWorldAgentExecutor(),
    task_store=InMemoryTaskStore(),
    agent_card=card,          # required in 1.0, was passed to the app wrapper in 0.3
)

routes = create_agent_card_routes(card) + create_jsonrpc_routes(handler, '/')
app = Starlette(routes=routes)

The executor is where the protocol shows up as code. Your execute gets a RequestContext and an EventQueue, and what you put on that queue is the wire.

async def execute(self, context, event_queue):
    task = context.current_task or new_task_from_user_message(context.message)
    await event_queue.enqueue_event(task)                    # Task MUST be first

    await event_queue.enqueue_event(new_text_status_update_event(
        task_id=task.id, context_id=task.context_id,
        state=TaskState.TASK_STATE_WORKING, text='Processing...'))

    result = await self.agent.invoke(get_message_text(context.message))

    await event_queue.enqueue_event(new_text_artifact_update_event(
        task_id=task.id, context_id=task.context_id, name='result', text=result))

    await event_queue.enqueue_event(new_text_status_update_event(
        task_id=task.id, context_id=task.context_id,
        state=TaskState.TASK_STATE_COMPLETED, text='Done!'))

In 0.3 you could mix a quick Message reply with task events and the server tolerated it. In 1.0 the server enforces the spec, and each of these is now InvalidAgentResponseError at runtime: sending a Message after a Task, sending more than one Message, sending a status update before the initial Task. You pick one pattern per stream. Either a single message and done, or a task followed by updates until a terminal state. That rule is the most common migration break in real executors, and it fails at runtime rather than at import.

The JavaScript SDK is the same three pieces with the arguments in a different order, which is worth seeing once if you are going to read samples in both languages.

import express from 'express';
import { AGENT_CARD_PATH, A2A_PROTOCOL_VERSION } from '@a2a-js/sdk';
import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
import { agentCardHandler, jsonRpcHandler, UserBuilder } from '@a2a-js/sdk/server/express';
import { SampleAgentExecutor } from './agent_executor.js';

const card = {
  name: 'Sample Agent',
  description: 'A sample agent to test the stream functionality.',
  supportedInterfaces: [{
    url: 'http://localhost:41241/',
    protocolBinding: 'JSONRPC',
    protocolVersion: A2A_PROTOCOL_VERSION,
  }],
  version: '1.0.0',
  capabilities: { streaming: true, pushNotifications: false, extensions: [], extendedAgentCard: false },
  defaultInputModes: ['text'],
  defaultOutputModes: ['text', 'task-status'],
  skills: [{ id: 'sample_agent', name: 'Sample Agent', description: 'Simulate a streaming agent.',
             tags: ['sample'], examples: ['hi', 'how are you'] }],
};

const handler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new SampleAgentExecutor());

const app = express();
app.use(`/${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: handler }));
app.use(jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication }));
app.listen(41241);

Both SDKs mount the card route separately from the protocol route, and that separation is deliberate: the card is public and cacheable, the protocol endpoint is authenticated. Putting them behind the same middleware is the first mistake to avoid, because a card nobody can fetch without a token is a card no new client can discover.

The client half, which most write-ups skip

Everything above is the server. The other half is the code that consumes an agent, and in v1.0 it is three calls: resolve the card, build a client from it, iterate the stream. The SDK picks the transport for you by walking supportedInterfaces in order.

import httpx, uuid
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.helpers import get_artifact_text, get_message_text
from a2a.types import Message, Part, Role, SendMessageRequest, TaskState

async with httpx.AsyncClient() as http:
    resolver = A2ACardResolver(http, 'http://127.0.0.1:41241')
    card = await resolver.get_agent_card()          # GET /.well-known/agent-card.json

client = await create_client(card, client_config=ClientConfig())

message = Message(
    role=Role.ROLE_USER,
    message_id=str(uuid.uuid4()),
    parts=[Part(text='Route Mountain View to SFO, avoid tolls')],
    context_id=context_id,        # reuse to keep the conversation
    task_id=task_id,              # set only when continuing an existing task
)

async for event in client.send_message(SendMessageRequest(message=message)):
    if event.HasField('message'):
        print('direct reply:', get_message_text(event.message))
    elif event.HasField('task'):
        task_id = event.task.id
    elif event.HasField('status_update'):
        print('state:', TaskState.Name(event.status_update.status.state))
    elif event.HasField('artifact_update'):
        print('artifact:', get_artifact_text(event.artifact_update.artifact))

That HasField chain is the v1.0 shape showing through. Every frame is a StreamResponse with exactly one member set, so a client is a four-way switch and nothing more. The pattern that bites: your loop must treat message as a complete answer with no task behind it, because a server is allowed to skip task creation entirely for a cheap request.

Termination is your job too. The stream ends when a status update carries one of the four terminal states, and the sample client hardcodes exactly that check.

if TaskState.Name(event.status_update.status.state) in (
    'TASK_STATE_COMPLETED', 'TASK_STATE_FAILED',
    'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED',
):
    current_task_id = None          # this task is done; a new message starts a new task

The wire changed underneath you

v1.0's other breaking change is quieter and reaches every parser you wrote. The kind discriminator is gone. Polymorphic objects now identify themselves by which JSON member is present, which is how Protocol Buffers oneof works, and the whole type system moved to proto with ProtoJSON as the canonical serialization. That is also why every enum you handled as "working" is now "TASK_STATE_WORKING".

Fig. 5 · the same event, before and after v1.0

Pick a payload version and the parser reading it. Three of the four combinations are what a mixed fleet actually looks like during a migration.

Shapes from Appendix A.2.1 of the spec; compatibility behavior from the Python SDK's v0.3 to v1.0 migration guide.

If you learned A2A from a 2025 write-up, here is the whole delta in one place, so you can tell which of your notes still hold.

What you read in 2025What it is at v1.0Changed in
/.well-known/agent.json/.well-known/agent-card.json, a registered well-known URI0.3, July 2025
AgentCard.url, one endpointsupportedInterfaces, ordered, one entry per binding1.0
Methods named tasks/send, message/sendSendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, SubscribeToTask1.0
States submitted, working, completedTASK_STATE_SUBMITTED and friends, plus REJECTED, AUTH_REQUIRED, UNSPECIFIED1.0
Parts tagged with "kind"Member name is the discriminator: text, raw, url, data1.0
supportsAuthenticatedExtendedCard on the cardcapabilities.extendedAgentCard1.0
SDKs: Python and Go, through one frameworkSix SDK repos in the a2aproject org: Python, JavaScript, Java, Go, C#, Rust, plus an integration testing kitthrough 2025 and 2026

The rest of the Python migration is mechanical but wide: the application wrapper classes are gone in favor of route factories, so you compose A2A routes into your own Starlette or FastAPI app and keep your middleware; ClientFactory became await create_client(...); push_notification_config is singular now; helpers consolidated under a2a.helpers; and send_message yields StreamResponse objects you inspect with HasField('artifact_update') instead of isinstance checks. The SDKs ship v1.0 with a 0.3 compatibility mode: the Python SDK covers all three bindings for both spec versions, and the JavaScript SDK reached 1.0 general availability on July 22, 2026, four months after the spec froze. That four month gap is the number to plan around, because the project now carries six language SDKs and they do not land together.

One call, three bindings, and curl for the impatient

The spec requires the three bindings to be functionally equivalent, and it publishes the mapping so you can move between them without guessing. Same operation, three spellings.

OperationJSON-RPC methodgRPC methodREST endpoint
Send messageSendMessageSendMessagePOST /message:send
Stream messageSendStreamingMessageSendStreamingMessage (server stream)POST /message:stream
Get taskGetTaskGetTaskGET /tasks/{id}
List tasksListTasksListTasksGET /tasks
Cancel taskCancelTaskCancelTaskPOST /tasks/{id}:cancel
ResubscribeSubscribeToTaskSubscribeToTask (server stream)POST /tasks/{id}:subscribe
Create push configCreateTaskPushNotificationConfigCreateTaskPushNotificationConfigPOST /tasks/{id}/pushNotificationConfigs
Extended cardGetExtendedAgentCardGetExtendedAgentCardGET /extendedAgentCard

The REST binding is the one you can debug from a terminal, which makes it the one to bring up first even if you ship gRPC later.

# 1. read the card
curl -s https://agent.example.com/.well-known/agent-card.json | jq '.supportedInterfaces'

# 2. send a message and block until the task settles
curl -s -X POST https://agent.example.com/a2a/rest/message:send \
  -H 'Content-Type: application/a2a+json' \
  -H 'A2A-Version: 1.0' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"message":{"role":"ROLE_USER","messageId":"msg-91",
       "parts":[{"text":"Route Mountain View to SFO, avoid tolls"}]}}'

# 3. or stream it, and watch the frames arrive
curl -N -X POST https://agent.example.com/a2a/rest/message:stream \
  -H 'Content-Type: application/a2a+json' -H 'A2A-Version: 1.0' \
  -d '{"message":{"role":"ROLE_USER","messageId":"msg-92","parts":[{"text":"..."}]}}'

# 4. poll one task, or list the ones still running in this conversation
curl -s "https://agent.example.com/a2a/rest/tasks/task-7c2?historyLength=10" -H 'A2A-Version: 1.0'
curl -s "https://agent.example.com/a2a/rest/tasks?contextId=ctx-19&status=TASK_STATE_WORKING&pageSize=50" \
  -H 'A2A-Version: 1.0'

That last call is ListTasks, which v1.0 added and which quietly changes what an A2A deployment can be. Before it, a client that lost its task IDs had lost the work; now there is a filtered, cursor-paginated listing, sorted by last update, capped at 100 per page, scoped by the spec to tasks the caller is authorized to see. It is the difference between a protocol for one exchange and a protocol for an operations console.

An unsigned agent card is a suggestion

Everything above assumes the card you fetched is the card the agent published. Threat modeling work on A2A has been consistent about where that assumption breaks. The Cloud Security Alliance's MAESTRO analysis put spoofed cards and weak server identity at the top of the agent-framework layer. Palo Alto Networks' writeup named the two field cases: shadowing, where a card mimics a trusted one and changes only the endpoint URL, and context poisoning, where the descriptions and example prompts inside a card carry injection payloads into the client agent's prompt. The card is fetched over the network, it goes into a model's context, and it is the only thing telling your client where to send work. Treat it as untrusted input in both roles.

The answer is a signature block on the card. The signatures field arrived in 0.3, and v1.0 pinned down how to produce and check it: JSON Web Signature per RFC 7515, over a payload canonicalized with the JSON Canonicalization Scheme, RFC 8785. The steps are exact, which is what makes them implementable.

Fig. 6 · canonicalize, sign, verify, tamper

Step through what a verifier does to a card before it trusts an endpoint. The digest is computed in your browser with SHA-256 over the canonical string, so the tamper switch really does change it.

Canonicalization and signature rules from spec sections 8.4.1 to 8.4.3. The demo applies the key ordering and whitespace rules of RFC 8785 and shows a real SHA-256 of the canonical payload; a production verifier checks a JWS signature over that payload with the key named by kid, fetched from the jku key set.

Both SDKs ship the primitives, so you are wiring, not implementing. In Python, signing is a callable you hand the card, and verification is a callable that fetches keys and raises on failure.

from a2a.utils.signing import create_agent_card_signer, create_signature_verifier

sign = create_agent_card_signer(
    signing_key=private_jwk,
    protected_header={'alg': 'ES256', 'typ': 'JOSE', 'kid': 'key-1',
                      'jku': 'https://georoute-agent.example.com/jwks.json'},
)
signed_card = sign(agent_card)      # canonicalizes with JCS, appends to card.signatures

verify = create_signature_verifier(key_provider=jwks_lookup, algorithms=['ES256'])
verify(fetched_card)                # raises NoSignatureError / InvalidSignaturesError

The JavaScript SDK exposes the same three moving parts under different names: canonicalizeAgentCard, verifyAgentCardSignature, and an AgentCardSignatureGenerator hook on the request handler. If you only do one security thing this quarter, make it client-side verification, because an unverified card is a URL a stranger chose for you.

Signing fixes card integrity. It does not fix identity, and the spec is careful about the difference: verify the signature, then decide separately whether the organization behind that key is one you delegate work to. Two more rules from the security section belong in your handler on day one. Do not distinguish "task does not exist" from "you may not see this task", because the difference is an enumeration oracle. And scope every task read to the caller that created it, since task IDs are the only handle the protocol has.

Wiring push notifications, both ends

Streaming is the easy path and the wrong one for anything that outlives a deploy. Webhooks are three objects on the server: a config store, a sender, and the handler that owns both.

from a2a.server.tasks import (
    BasePushNotificationSender, InMemoryPushNotificationConfigStore, InMemoryTaskStore,
)

push_config_store = InMemoryPushNotificationConfigStore()

handler = DefaultRequestHandler(
    agent_executor=MyAgentExecutor(),
    task_store=InMemoryTaskStore(),
    agent_card=card,                       # capabilities.push_notifications MUST be true
    extended_agent_card=extended_card,     # optional, served only to authenticated callers
    push_config_store=push_config_store,
    push_sender=BasePushNotificationSender(
        httpx_client=notification_client,
        config_store=push_config_store,
    ),
)

The client registers a webhook against a task, and from then on every frame it would have received on the stream arrives as an HTTP POST instead.

curl -s -X POST https://agent.example.com/a2a/rest/tasks/task-7c2/pushNotificationConfigs \
  -H 'Content-Type: application/a2a+json' -H 'A2A-Version: 1.0' \
  -d '{"url":"https://ops.example.com/a2a/hooks/task-7c2",
       "token":"opaque-per-task-secret",
       "authentication":{"scheme":"Bearer","credentials":"secret-for-this-task"}}'

# or attach the same config to the first message, so it is live from the start
curl -s -X POST https://agent.example.com/a2a/rest/message:send \
  -H 'Content-Type: application/a2a+json' -H 'A2A-Version: 1.0' \
  -d '{"message":{"role":"ROLE_USER","messageId":"msg-93","parts":[{"text":"Generate the Q1 report"}]},
       "configuration":{"taskPushNotificationConfig":{
         "url":"https://ops.example.com/a2a/hooks/q1",
         "authentication":{"scheme":"Bearer","credentials":"secure-client-token"}}}}'

Note on that first call: the spec lists the create-config fields in section 3.1.7 and states that REST bodies are structurally equivalent to the Protocol Buffer definitions, but it publishes no worked REST example for this endpoint, so the flat body above is read from the field list rather than copied from the spec. The second form, the config attached to message:send, is taken verbatim from the spec's own section 6.6 example.

Use a distinct token per config, not one shared secret, because that token is the only thing telling your receiver the POST came from the agent you hired. Rotate it, verify it in constant time, and keep the handler idempotent: the spec guarantees at-least-once delivery and explicitly allows duplicates.

Tasks that survive a restart

The default task store is in memory, which is correct for a sample and wrong for anything a customer touches. A task is a durable object in the protocol's model: clients may resubscribe to it, list it, or ask for it hours later, and all of that fails if your process forgot. Swap the store and keep everything else.

pip install "a2a-sdk[postgresql]"     # or [mysql], [sqlite], [sql] for all three
from sqlalchemy.ext.asyncio import create_async_engine
from a2a.server.tasks import DatabaseTaskStore

engine = create_async_engine('postgresql+asyncpg://user:pass@localhost/a2a')
task_store = DatabaseTaskStore(engine=engine)

handler = DefaultRequestHandler(
    agent_executor=MyAgentExecutor(),
    task_store=task_store,
    agent_card=card,
)

There is a matching DatabasePushNotificationConfigStore, and the pairing matters: a webhook registration that lives only in memory disappears on the deploy that happens while the task is still running, which is exactly the case webhooks existed to cover.

Authorization is a task state, not a header

The feature I did not expect to find in a v1.0 protocol is in-task authorization, and it is the clearest sign that A2A is designed for work that takes minutes rather than milliseconds. When an agent hits something it cannot do without a credential or a human approval, it moves the task to TASK_STATE_AUTH_REQUIRED and puts an explanation in the status message. The client can answer, negotiate, refuse, or, if the client is itself an agent serving its own task, move its task to AUTH_REQUIRED and pass the request up. Authorization requests chain the same way delegation does.

Two constraints keep that from becoming a credential leak. Credentials should arrive out of band over a channel the requesting agent controls, because in-band credentials passing through a chain are readable by every agent in the chain. And the state transition itself grants nothing: the spec says an agent must not treat AUTH_REQUIRED, by itself, as authorization for any operation, and a credential obtained during one interruption must not be assumed to cover later messages on the same task. If you have built an approval flow inside an agent framework, this is the same problem with the escalation path written down.

How to know it actually conforms

Three tools exist, and using them is faster than reading your own logs. The A2A Inspector is a web UI that talks to any agent and shows the raw frames next to a validation report, which is where card mistakes surface first. The Integration Testing Kit is the cross-SDK conformance harness the project runs against its own implementations, so it is the closest thing to a compliance suite. And the SDKs ship compatibility samples that run a v1.0 server against both a v1.0 and a hand-rolled v0.3 client in one process, which is the cheapest way to prove your compat flag does what you think.

A short bring-up order that avoids the usual dead ends: serve the card and check it in the Inspector before writing any executor logic; send one non-streaming message and confirm you get a task rather than a bare message; open the stream and assert the first event is a Task; then register a webhook and kill the client mid-task to prove delivery is independent of your process.

What A2A still does not give you

A protocol is as useful as its edges are clear. Four things are outside them today. There is no standard registry: discovery is a well-known path plus "querying curated catalogs", and which catalog is your problem, so at fleet scale you are building or buying an index. There is no semantics for skills: AgentSkill is names, tags, and examples, which means matching a task to an agent is still a model call or your own routing table. Multi-tenancy is an opaque string: tenant is echoed and routed by the server, with no defined format, which is honest but pushes the design onto you. And settlement is a separate protocol: the commerce work Google Cloud and PayPal are doing runs the payment authorization layer beside A2A rather than inside it.

The gap builders complain about most is observability. A single request can now cross an A2A boundary into an agent that calls MCP servers through a gateway, and each layer is instrumented separately. The A2A half of that trace is at least legible: tasks have IDs, contexts group them, artifacts are addressable, and the SDK ships OpenTelemetry as an extra. Start emitting spans keyed on taskId and contextId before you have a fleet, not after.

What to build this week

If you want a working agent rather than an opinion about protocols, the path is short. Publish a card at /.well-known/agent-card.json with one interface and honest capabilities, because advertising streaming you do not implement is what produces PushNotificationNotSupportedError in someone else's logs. Pick a single binding to start: JSON-RPC has the widest client support, HTTP+JSON is the one you can debug with curl, gRPC is worth it when you are inside one cluster. Write the executor task-first and pick one streaming pattern. Set returnImmediately: true on anything slower than a page load, and register a webhook rather than holding a connection open across a deploy. Then run the A2A Inspector against your agent and read the raw frames it shows you, because the first bug is almost always in the card, not the code.

The governance move that happened today matters for exactly one practical reason. The version policy, the deprecation path, and the meetings where breaking changes get argued are now in a neutral foundation with published technical steering meetings, which is a different kind of dependency than a protocol a single vendor ships on its own schedule. Given that v1.0 already broke every 0.3 parser once, knowing where the next break will be argued is worth as much as the spec itself.

rg
Rohit Ghumare

CNCF Ambassador and Google Developer Expert. I build agent infrastructure and write about the fundamentals underneath the AI stack. Spec behavior, SDK versions, and dates here come from the A2A specification, the SDK repositories, and the foundation's own announcements, read on August 17, 2026. Protocol details change between minor versions, so check the version your card advertises before relying on any of it.

Related: Stateless MCP · My earlier MCP vs A2A guide · More posts · X