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.
What the A2A 1.0 Specification Actually Standardises
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.
Publishing the Agent Card at /.well-known/agent-card.json
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..." } ]
}- supportedInterfaces is an ordered list, and the first entry is the preferred one. Clients must pick the first entry whose binding they support and use that entry's URL, so ordering is a routing decision, not cosmetics.
- Each interface declares its own protocolVersion. That is how a single card advertises a 1.0 endpoint alongside a legacy 0.3 one during a migration.
- securitySchemes declares how to authenticate, and securityRequirements says which scheme applies. The credentials themselves are obtained out of band; the card never carries them.
- Required fields are name, description, version, supportedInterfaces, capabilities, defaultInputModes, defaultOutputModes and skills. A skill needs an id, name, description and tags; examples are optional but they are what another agent's planner reads.
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.
Signing the Agent Card With JWS and RFC 8785
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:
- Remove properties that carry default values, following Protocol Buffers field-presence rules: unset optional fields are dropped, explicitly set optional fields are kept, and required fields always stay.
- Exclude the signatures field itself, so the signature never signs itself.
- Canonicalise the remainder with RFC 8785: keys sorted lexicographically, insignificant whitespace removed, numbers and strings in one fixed form.
- Build a protected header containing alg, typ set to JOSE and kid, optionally jku pointing at a JWKS, then sign the base64url header joined by a dot to the base64url payload.
- Store the base64url protected header and signature as one entry in the signatures array; several entries let you rotate keys without a gap.
// 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.
The A2A Task Lifecycle and Its Nine States
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.
Building a Minimal A2A Server for an ERP Stock Check
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, Push Notifications and Choosing a Binding
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" } } }- JSON-RPC 2.0 is the one to start with: a single POST endpoint, PascalCase method names, application/json, SSE for streams, and A2A-specific errors in the -32001 to -32099 range, with TaskNotFoundError at -32001.
- HTTP+JSON maps operations onto resource URLs with application/a2a+json, and its errors map onto HTTP statuses, 404 for a missing task and 400 for most precondition failures. Pick it when the caller sits behind an API gateway that routes and rate-limits by path.
- gRPC uses Protocol Buffers over HTTP/2 with the same method names. Pick it for internal agent meshes where both ends already run gRPC, not for a partner on the open internet behind a proxy that may not pass HTTP/2 cleanly.
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.
Security Gaps in A2A That the Spec Leaves to You
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:
- Cross-client context injection: a contextId is an unprotected identifier, so bind every context and task to the authenticated principal that created it and reject messages from anyone else.
- Unverified webhook URLs: an agent that POSTs to any URL a client supplies is an SSRF primitive. The spec already says to reject private ranges such as 10.0.0.0/8 and 192.168.0.0/16, loopback and link-local addresses, and to allowlist where possible.
- Multi-hop identity loss: identity lives at the transport layer, so when your agent delegates to a third agent, the original partner's identity does not travel. Do not let a downstream agent infer authority from the fact that your agent called it.
- Auth scope amplification: credentials supplied to resolve TASK_STATE_AUTH_REQUIRED must not widen what the task may do. The spec states the state transition by itself authorises nothing.
- No timeout from interrupted states: an INPUT_REQUIRED task can wait forever and hold resources. Put your own expiry on interrupted tasks and move them to a terminal state.
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
- A2A Protocol Specification, latest release 1.0.0 (a2a-protocol.org)
- a2aproject/A2A releases: v1.0.0 and v1.0.1 (GitHub)
- a2aproject/a2a-js: official JavaScript SDK for A2A v1.0 (GitHub)
- Agent2Agent (Wikipedia)
- Lotfi, Rahman, Karim and Bertino, A2ABreak: Systematic Security Analysis of the A2A Protocol (arXiv 2609.10871)

