Backend
A2A Protocol Tutorial: v1.0 Agent Cards, Tasks and Bindings
October 202612 min read

Agent2Agent (A2A) is an open protocol, governed by the Linux Foundation, that lets one AI agent discover another agent and hand it work. The remote agent publishes an Agent Card describing its skills and authentication, and clients send it messages that create trackable tasks. MCP connects an agent to tools and data; A2A connects agents to each other.
The 1.0 specification registers the well-known URI /.well-known/agent-card.json, so a client can find an agent's card from its domain alone. Cards can also come from registries or direct configuration. The public card should be served without authentication and with Cache-Control and ETag headers, while an authenticated extended card is available through GetExtendedAgentCard if the agent enables it.
TaskState has nine values: UNSPECIFIED, SUBMITTED, WORKING, INPUT_REQUIRED, AUTH_REQUIRED, COMPLETED, FAILED, CANCELED and REJECTED, each prefixed with TASK_STATE_. COMPLETED, FAILED, CANCELED and REJECTED are terminal and accept no further messages. INPUT_REQUIRED and AUTH_REQUIRED are interrupted states, where the client replies or supplies credentials and the same task continues.
All three bindings expose the same eleven operations, so the choice is operational rather than functional. JSON-RPC 2.0 is the simplest to start with and debug, HTTP+JSON suits callers behind gateways that route by path, and gRPC fits internal meshes that already run HTTP/2. An Agent Card can list several interfaces in preference order, and clients pick the first one they support.
A2A requires TLS and authentication on every request, but it leaves authorisation to each implementation. The A2ABreak analysis reported 11 specification-level weaknesses, including cross-client context injection, unverified webhook URLs and identity loss across delegation chains. Bind tasks and contexts to the authenticated caller, validate webhook URLs against SSRF, and put timeouts on interrupted tasks.

Key Takeaway
A2A 1.0 is the Linux Foundation protocol for one agent calling another. An agent publishes an Agent Card at /.well-known/agent-card.json, accepts messages that create tasks moving through submitted, working, interrupted and terminal states, streams updates over Server-Sent Events or webhooks, and exposes the same eleven operations over JSON-RPC, gRPC or HTTP+JSON.
The question I started from was narrow: could a distributor's purchasing agent ask our ERP whether a SKU is in stock in a given warehouse, without another bespoke partner API and a PDF explaining it? A partner integration in ERP usually means exactly that pair, a REST endpoint plus a document, and the two drift apart as soon as either changes. Agent2Agent (A2A) offers a different contract: the agent describes itself in a machine-readable card, and any compliant client can discover it, authenticate, and hand it work.
This A2A protocol tutorial walks through the 1.0 specification as a developer has to implement it: the Agent Card and its signature, the task lifecycle and the states that trip people up, streaming versus push notifications, and the three standard bindings. It ends with a minimal server built on the official JavaScript SDK that exposes an ERP stock-check agent, and the security gaps a September 2026 analysis found in the protocol itself. Every field name and method below comes from the published specification or the SDK source.
A2A was announced by Google in April 2025 and handed to the Linux Foundation in June 2025; the specification repository tagged v1.0.0 in March 2026 and a v1.0.1 bug-fix release in May 2026. The spec is layered: a canonical data model defined in a Protocol Buffers file that is the single normative source, a set of abstract operations, and protocol bindings that map those operations onto the wire. The practical consequence is that the same eleven operations exist whichever binding you pick, with identical method names on JSON-RPC and gRPC.
| Operation | JSON-RPC and gRPC method | HTTP+JSON endpoint |
|---|---|---|
| Send a message | SendMessage | POST /message:send |
| Send a message and stream updates | SendStreamingMessage | POST /message:stream |
| Read one task | GetTask | GET /tasks/{id} |
| List tasks, cursor-paginated | ListTasks | GET /tasks |
| Cancel a task | CancelTask | POST /tasks/{id}:cancel |
| Resubscribe to a running task | SubscribeToTask | POST /tasks/{id}:subscribe |
| Register a webhook for a task | CreateTaskPushNotificationConfig | POST /tasks/{id}/pushNotificationConfigs |
| Read one webhook config | GetTaskPushNotificationConfig | GET /tasks/{id}/pushNotificationConfigs/{configId} |
| List a task's webhook configs | ListTaskPushNotificationConfigs | GET /tasks/{id}/pushNotificationConfigs |
| Delete a webhook config, idempotently | DeleteTaskPushNotificationConfig | DELETE /tasks/{id}/pushNotificationConfigs/{configId} |
| Fetch the authenticated extended card | GetExtendedAgentCard | GET /extendedAgentCard |
Two things in that table matter more than they look. ListTasks is new relative to 0.3 and the spec requires it to return only tasks the authenticated caller may see, sorted by last update. And every request should carry an A2A-Version header: the spec says an empty value is interpreted as 0.3, so a client that forgets it is silently negotiating the old protocol with any server that still speaks both.
The Agent Card is the agent's public contract, and the spec registers a well-known URI for it so discovery needs nothing but a domain. This is the card for the stock-check agent, served unauthenticated because a client has to read it before it knows how to authenticate.
GET /.well-known/agent-card.json HTTP/1.1
Host: stock-agent.example-erp.co.id
{
"name": "ERP Stock Check Agent",
"description": "Answers available-to-promise stock per SKU and warehouse for approved distributors.",
"version": "1.3.0",
"supportedInterfaces": [
{ "url": "https://stock-agent.example-erp.co.id/a2a/jsonrpc",
"protocolBinding": "JSONRPC", "protocolVersion": "1.0" },
{ "url": "https://stock-agent.example-erp.co.id/a2a/rest",
"protocolBinding": "HTTP+JSON", "protocolVersion": "1.0" }
],
"provider": { "organization": "Example Distribution", "url": "https://example-erp.co.id" },
"capabilities": {
"streaming": true,
"pushNotifications": true,
"extendedAgentCard": false
},
"securitySchemes": {
"partnerBearer": {
"httpAuthSecurityScheme": { "scheme": "bearer", "bearerFormat": "JWT" }
}
},
"securityRequirements": [ { "schemes": { "partnerBearer": { "list": [] } } } ],
"defaultInputModes": ["text/plain", "application/json"],
"defaultOutputModes": ["application/json"],
"skills": [
{
"id": "stock-availability",
"name": "Stock availability",
"description": "On-hand minus reserved quantity for one SKU in one warehouse.",
"tags": ["erp", "inventory", "stock"],
"examples": ["Is SKU BRG-10422 available in warehouse SBY-01?"]
}
],
"signatures": [ { "protected": "eyJhbGciOiJFUzI1NiIs...", "signature": "QFdkNLNs..." } ]
}Serve the card with a Cache-Control max-age and an ETag derived from the card version or a hash of its content, as the spec recommends, and clients will revalidate with If-None-Match instead of downloading it on every call. Keep internal hostnames and anything sensitive off it: the public card is, by definition, readable by anyone, and the spec warns that even the authenticated extended card should not carry internal service URLs.
Agent Cards may be signed with JSON Web Signature, RFC 7515, so a client can check that the card was not altered in transit or by a cache. The hard part is not the cryptography but producing the same bytes on both sides, which is why the spec mandates the JSON Canonicalization Scheme, RFC 8785, after a default-stripping pass. The signing procedure is:
// Before canonicalisation (as served):
{
"name": "Example Agent",
"description": "",
"capabilities": { "streaming": false, "pushNotifications": false, "extensions": [] },
"skills": []
}
// After default-stripping + RFC 8785 JCS -- this exact byte string is what gets signed.
// "extensions": [] is gone (repeated, not REQUIRED); "description": "" stays (REQUIRED);
// streaming:false stays because it was explicitly set on an optional field.
{"capabilities":{"pushNotifications":false,"streaming":false},"description":"","name":"Example Agent","skills":[]}
// Protected header, base64url-decoded:
{"alg":"ES256","typ":"JOSE","kid":"key-1","jku":"https://example.com/agent/jwks.json"}Verification reverses it: strip defaults, drop signatures, canonicalise, fetch the public key by kid and jku over HTTPS or from a trusted key store, and check. The official JavaScript SDK ships canonicalizeAgentCard and verifyAgentCardSignature helpers plus a signing hook on its request handler, so you should not be hand-rolling JCS. Where people do get burnt is serialising the card through a library that emits empty arrays or default booleans the signer omitted, which produces a valid-looking card whose signature never verifies.
A valid signature proves who published the card, not that the skills it lists are real or safe. The A2ABreak analysis lists unattested skill claims and the gap between key trust and capability claims as two separate findings. Treat a signed card as authenticated advertising, and pin the jku domain to the provider you actually contracted with.
A Message is one turn; a Task is the stateful unit of work a message may create. The server can answer a SendMessage with a direct Message for trivial requests or with a Task it tracks. TaskState defines nine values, the first being TASK_STATE_UNSPECIFIED, and the remaining eight split into three behavioural groups that your client code has to treat differently.
| State | Kind | What the client should do |
|---|---|---|
TASK_STATE_SUBMITTED | Active | Acknowledged, not started. Keep the stream open or poll. |
TASK_STATE_WORKING | Active | In progress. Expect status and artifact events. |
TASK_STATE_INPUT_REQUIRED | Interrupted | Read the status message and reply on the same taskId. |
TASK_STATE_AUTH_REQUIRED | Interrupted | Obtain the credential out of band, then subscribe, poll or reply. |
TASK_STATE_COMPLETED | Terminal | Read the artifacts. The task accepts no further messages. |
TASK_STATE_FAILED | Terminal | Log the status message. Retry means a new task. |
TASK_STATE_CANCELED | Terminal | Confirm the cancel landed. CancelTask is a request, not a guarantee. |
TASK_STATE_REJECTED | Terminal | The agent declined. Do not retry the same input blindly. |
Blocking is the default. Unless the client sets returnImmediately to true in the send configuration, SendMessage must wait until the task reaches a terminal or interrupted state before it returns. That is fine for a stock lookup and fatal for a three-hour reconciliation behind a 60-second load-balancer timeout, so long-running agents should be called non-blocking, with a stream, polling or a webhook carrying the result. Sending a message to a task in a terminal state returns UnsupportedOperationError; a follow-up after completion belongs in a new task under the same contextId.
1.0 removed the kind discriminator. In 0.3 a text part was an object with kind set to text; in 1.0 the member name is the discriminator, so a text part is just an object with a text member, and stream events arrive wrapped as statusUpdate or artifactUpdate. Enum values changed form too, to TASK_STATE_COMPLETED and ROLE_USER. A hand-written 0.3 parser fails on the very first 1.0 event, so check every client you do not control before switching off compatibility mode.
The official @a2a-js/sdk reached its v1.0 stable line in July 2026 and implements all three bindings behind one DefaultRequestHandler. Your logic lives in an AgentExecutor, which receives a RequestContext and publishes Task, status and artifact events to an event bus. The executor below is the whole agent: it rejects unknown partners, asks for missing input rather than failing, checks warehouse access, and returns the answer as a structured data part.
// stock-executor.ts -- npm install @a2a-js/sdk express
import { Task, TaskState, Role, Part, Message } from "@a2a-js/sdk";
import {
AgentExecutor, AgentEvent, RequestContext, ExecutionEventBus,
} from "@a2a-js/sdk/server";
import { erp } from "./erp-client"; // your existing inventory service
import { PartnerUser } from "./partner-auth";
const SKU = /\b[A-Z]{3}-\d{4,6}\b/;
const WAREHOUSE = /\b[A-Z]{3}-\d{2}\b/;
const text = (value: string): Part => ({
content: { $case: "text", value }, metadata: undefined, filename: "", mediaType: "text/plain",
});
function agentMessage(ctx: RequestContext, value: string): Message {
return {
role: Role.ROLE_AGENT, messageId: crypto.randomUUID(), parts: [text(value)],
taskId: ctx.taskId, contextId: ctx.contextId, extensions: [], metadata: {}, referenceTaskIds: [],
};
}
export class StockExecutor implements AgentExecutor {
async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
const { taskId, contextId } = ctx;
const status = (state: TaskState, note?: string) =>
bus.publish(AgentEvent.statusUpdate({
taskId, contextId, metadata: undefined,
status: { state, timestamp: new Date().toISOString(),
message: note ? agentMessage(ctx, note) : undefined },
}));
// A streaming turn MUST open with a Task (or a single Message).
const snapshot: Task = ctx.task ?? {
id: taskId, contextId, artifacts: [], history: [ctx.userMessage], metadata: {},
status: { state: TaskState.TASK_STATE_SUBMITTED, timestamp: new Date().toISOString(),
message: undefined },
};
bus.publish(AgentEvent.task(snapshot));
const partner = ctx.context?.user;
if (!(partner instanceof PartnerUser)) {
return status(TaskState.TASK_STATE_REJECTED, "Unknown partner.");
}
// Read every text part across the task history, so a follow-up turn that
// only says "SBY-01" still finds the SKU sent in the first turn.
const said = [...(ctx.task?.history ?? []), ctx.userMessage]
.flatMap((m) => m.parts)
.map((p) => (p.content?.$case === "text" ? p.content.value : ""))
.join(" ");
const sku = said.match(SKU)?.[0];
const warehouse = said.match(WAREHOUSE)?.[0];
// Interrupted, not failed: the same taskId continues when the partner answers.
if (!sku || !warehouse) {
return status(TaskState.TASK_STATE_INPUT_REQUIRED,
"Send the SKU (e.g. BRG-10422) and warehouse code (e.g. SBY-01).");
}
// Authorise against the partner's contract, not against what the card advertises.
if (!partner.warehouses.includes(warehouse)) {
return status(TaskState.TASK_STATE_REJECTED, `No access to warehouse ${warehouse}.`);
}
status(TaskState.TASK_STATE_WORKING);
const { onHand, reserved } = await erp.stockLevel(sku, warehouse);
bus.publish(AgentEvent.artifactUpdate({
taskId, contextId, append: false, lastChunk: true, metadata: undefined,
artifact: {
artifactId: crypto.randomUUID(), name: "stock-level", description: "",
metadata: undefined, extensions: [],
parts: [{
content: { $case: "data",
value: { sku, warehouse, onHand, reserved, available: onHand - reserved } },
metadata: undefined, filename: "", mediaType: "application/json",
}],
},
}));
status(TaskState.TASK_STATE_COMPLETED);
}
// Stock lookups finish in one round trip; there is nothing to abort mid-flight.
cancelTask = async (): Promise<void> => {};
}Three decisions in it are deliberate. Missing input becomes TASK_STATE_INPUT_REQUIRED rather than FAILED, because an interrupted task keeps its taskId and the partner's agent can simply reply. Authorisation is checked against the partner's contract inside the executor, because the card advertises what the agent can do for anyone, not what this caller may do. And the result is a data part with mediaType application/json, so the calling agent parses numbers rather than prose. Wiring it to HTTP takes a few lines:
// server.ts
import express from "express";
import { AGENT_CARD_PATH } from "@a2a-js/sdk";
import { DefaultRequestHandler, InMemoryTaskStore } from "@a2a-js/sdk/server";
import { agentCardHandler, jsonRpcHandler, restHandler } from "@a2a-js/sdk/server/express";
import { stockAgentCard } from "./agent-card"; // the card shown above, as an AgentCard
import { StockExecutor } from "./stock-executor";
import { requirePartnerToken, partnerUserBuilder } from "./partner-auth";
// InMemoryTaskStore loses every task on restart -- an INPUT_REQUIRED task the
// partner answers after a deploy becomes TaskNotFoundError (-32001).
// Swap in DatabaseTaskStore from @a2a-js/sdk/server/database for production.
const handler = new DefaultRequestHandler(stockAgentCard, new InMemoryTaskStore(), new StockExecutor());
const app = express();
// The public card stays unauthenticated: discovery happens before credentials exist.
app.use(`/${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: handler }));
// Everything else requires the partner's bearer token (spec 7.4: authenticate EVERY request).
app.use("/a2a", requirePartnerToken);
app.use("/a2a/jsonrpc", jsonRpcHandler({ requestHandler: handler, userBuilder: partnerUserBuilder }));
app.use("/a2a/rest", restHandler({ requestHandler: handler, userBuilder: partnerUserBuilder }));
app.listen(8080);The partner-auth module, not shown, is ordinary Express middleware that validates the bearer token and a UserBuilder that turns the validated request into a PartnerUser object holding the partner id and its permitted warehouses; the SDK's authentication sample uses the same pattern with Passport. Mounting both jsonRpcHandler and restHandler on the same request handler means one card can list both interfaces with no duplicated logic. The gRPC binding mounts the same way through grpcService from the gRPC subpath, but it needs the grpc-js and protobuf peer dependencies and is Node-only.
Streaming over the JSON-RPC and HTTP+JSON bindings is Server-Sent Events. Every event is a StreamResponse containing exactly one of task, message, statusUpdate or artifactUpdate, and a task stream must begin with the Task object and close when the task reaches a terminal state. If the connection drops, SubscribeToTask reattaches and, by specification, sends the current Task first so nothing is lost between a GetTask and the new stream.
curl -N https://stock-agent.example-erp.co.id/a2a/jsonrpc \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $PARTNER_TOKEN" \
-H "A2A-Version: 1.0" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "SendStreamingMessage",
"params": { "message": {
"messageId": "9b7c1f0e-0d5a-4f43-9d1e-2a1f6b1f3c11", "role": "ROLE_USER",
"parts": [ { "text": "Is BRG-10422 available in SBY-01?" } ] } }
}'
# Content-Type: text/event-stream -- one StreamResponse per event, exactly one member set:
data: {"jsonrpc":"2.0","id":1,"result":{"task":{"id":"t-81","contextId":"c-12","status":{"state":"TASK_STATE_SUBMITTED"}}}}
data: {"jsonrpc":"2.0","id":1,"result":{"statusUpdate":{"taskId":"t-81","contextId":"c-12","status":{"state":"TASK_STATE_WORKING"}}}}
data: {"jsonrpc":"2.0","id":1,"result":{"artifactUpdate":{"taskId":"t-81","contextId":"c-12","lastChunk":true,"artifact":{"artifactId":"a-1","name":"stock-level","parts":[{"data":{"sku":"BRG-10422","warehouse":"SBY-01","onHand":340,"reserved":120,"available":220},"mediaType":"application/json"}]}}}}
data: {"jsonrpc":"2.0","id":1,"result":{"statusUpdate":{"taskId":"t-81","contextId":"c-12","status":{"state":"TASK_STATE_COMPLETED"}}}}
# stream closes on the terminal stateWhen the caller cannot hold a connection open, push notifications deliver the same StreamResponse shapes to a webhook. The configuration can ride along in the send request, as below over the REST binding, or be registered later with CreateTaskPushNotificationConfig. Delivery is at-least-once, so the receiver must answer with a 2xx status, treat duplicates as normal, and check that the taskId belongs to a task it actually created.
# Same operation over the HTTP+JSON binding, fire-and-forget with a webhook.
POST /a2a/rest/message:send HTTP/1.1
Content-Type: application/a2a+json
Authorization: Bearer <partner token>
A2A-Version: 1.0
{
"message": { "messageId": "4e0d...", "role": "ROLE_USER",
"parts": [ { "text": "Reorder check: BRG-10422 in SBY-01" } ] },
"configuration": {
"returnImmediately": true, // default false = block until terminal/interrupted
"taskPushNotificationConfig": {
"url": "https://partner.example.com/a2a/webhook",
"token": "per-task-random-value", // echo-check this on every delivery
"authentication": { "scheme": "Bearer", "credentials": "<webhook secret>" }
}
}
}
# The agent later POSTs the same StreamResponse shapes to the webhook:
POST /a2a/webhook Content-Type: application/a2a+json Authorization: Bearer <webhook secret>
{ "statusUpdate": { "taskId": "t-82", "contextId": "c-12", "status": { "state": "TASK_STATE_COMPLETED" } } }Nothing stops you exposing all three; the spec only asks that every listed interface be functionally equivalent. For a partner-facing agent, list JSON-RPC first because it is the easiest to debug with curl, and add REST second only when a partner's gateway needs path-based routing. Every extra binding is another surface to authenticate and test.
A2ABreak, a paper posted to arXiv in September 2026 by researchers including Elisa Bertino, modelled the A2A specification as a state machine of 37 states and 76 transitions and reported 11 vulnerabilities exploitable by an adversary who follows the specification exactly. These are not implementation bugs you can patch away by upgrading a library; they are places where a compliant server is still unsafe unless you add a control. The ones that matter most for a partner-facing ERP agent:
The rest of the list covers unattested skill claims, signatures that authenticate identity but not capability, SSE streams that keep leaking after revocation, artifact chunks reassembled without integrity checks, concurrent-modification races, and circular delegation. The common thread is that A2A defines how agents talk, deliberately leaving who may do what to each implementation. Section 13.1 of the spec says it plainly: authorisation checks must happen on every operation and before any query that could reveal whether a resource exists.
Scope GetTask and ListTasks to the caller before touching the database. Returning TaskNotFoundError for another partner's task is correct; returning a permission error tells the caller the taskId exists. With sequential or guessable task IDs, that difference turns your stock agent into an oracle for a competitor's order volume.
A2A 1.0 gives an ERP team something a REST endpoint plus a PDF never did: a self-describing, signable contract with a real task lifecycle and a choice of transport. What it does not give you is authorisation, expiry or identity across hops, so the rule I carry is simple: let the protocol carry the conversation, and keep every decision about who may see which stock in your own code, checked on every call.
Sources