Agentic AI Foundation Logo
Human shaking hands with a robot beside a task progress card, titled A2A v1.0 Builder's Guide Part 1 by Rohit Ghumare

A2A v1.0: a builder's guide, part 1 - discovery, tasks, and clients

Rohit GhumareAugust 27, 2026

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 on August 17 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.

In part 1, we'll cover the core v1.0 flow: discovery, task lifecycle, server and client behavior, and the ways clients can follow work as it progresses.

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.

[Fig. 1 is an interactive figure on the site; a static screenshot is included in the .docx version. Open it at https://rohitghumare.com/blog/a2a-protocol/#dg]

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, and A2A has support from more than 150 organizations. Its move into AAIF places it 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.

python
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: if pushNotifications is false or absent, push notification operations must return PushNotificationNotSupportedError. 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.

[Fig. 2 is an interactive figure on the site; a static screenshot is included in the .docx version. Open it at https://rohitghumare.com/blog/a2a-protocol/#nf]

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.

[Fig. 3 is an interactive figure on the site; a static screenshot is included in the .docx version. Open it at https://rohitghumare.com/blog/a2a-protocol/#ts]

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 do not require the client to keep an A2A connection open.

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.

[Fig. 4 is an interactive figure on the site; a static screenshot is included in the .docx version. Open it at https://rohitghumare.com/blog/a2a-protocol/#dl]

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.

typescript
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.

python
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.

typescript
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.

python
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

Next: migration, security, and production

That gives you the core v1.0 flow from discovery through task completion. Part 2 looks at what changed on the wire, how to migrate from 0.3, and the security and operational pieces that matter once you move beyond the basic exchange.

Share

Author

subscription section bg
Subscribe

Subscribe to the AAIF Briefing

Weekly signal on standards, governance, and the people building the future. No fluff. Just what matters.

About AAIF