Skip to main content

A2A Protocol in Java: Agents That Talk to Each Other (and How A2A Differs from MCP)

Two Java agents talk over the A2A protocol: the agent card, a raw SendMessage call, blocking versus streaming, input-required follow-ups and an agent calling another agent, with every message shown and a table comparing A2A with MCP.

Most of the AI code you write today talks to a model, or to tools through MCP. Sooner or later you hit a different problem: another team has built an agent, perhaps in another language or framework, and you want yours to hand it a job. You do not want to reach inside it. You want to send it a message, get back something that tells you whether it is working, finished, or needs more from you, and collect the result. That is what the Agent2Agent protocol, A2A, is for. This article builds two small Java agents that talk to each other over A2A and shows every message on the wire. You will see the “business card” an agent publishes, a single request that returns a task, what changes when you stream, how an agent asks for more input and the same task carries on, and what happens when one agent fails while serving another. Then a table puts A2A next to MCP, because people mix them up. Depth is in expandable sections, so you can read straight through or open only what you need.
Versions, and honest limits. A2A protocol 1.0, using the Java SDK io.github.a2asdk 1.0.0.Alpha3, on Java 25 with JUnit 6.1.3. That is a pre-release: the newest stable release of the SDK I found is 0.3.3.Final, which targets the older 0.3 protocol and has a different API, so none of the code here compiles against it. All the code is in the a2a module of asmhatre/java-ai-agents, and every console block below is quoted from a file under a2a/output/, written by a test that asserts the same facts. No language model is involved. The two agents are plain Java, because the protocol is the subject. I used the JSON-RPC binding only: no gRPC, no REST binding, no push notifications, no authentication, and no Spring Boot starter. The SDK’s server side is written for Quarkus, so I wrote a small server on the JDK’s built-in HTTP server to host it; that glue is described in a section near the end, including one workaround that uses reflection.

An agent starts by publishing a card

Before anyone can send an agent a message they need to know what it is, where it listens, and what it can do. A2A answers that with an agent card: a JSON document served from a well-known path on the agent’s host, /.well-known/agent-card.json. The first agent in this article, the Glossary Agent, defines Java terms. Its card is built in GlossaryAgent.java:
    public static AgentCard card(String url) {
        return AgentCard.builder()
                .name("Glossary Agent")
                .description("Defines Java terms")
                .version("1.0.0")
                .supportedInterfaces(List.of(new AgentInterface("JSONRPC", url)))
                .capabilities(AgentCapabilities.builder().streaming(true).build())
                .defaultInputModes(List.of("text/plain"))
                .defaultOutputModes(List.of("text/plain"))
                .skills(List.of(AgentSkill.builder()
                        .id("define").name("Define a term").description("Returns a one-sentence definition")
                        .tags(List.of("java", "glossary")).examples(List.of("record")).build()))
                .build();
    }
A test fetches that path from a running server and keeps the response, 01-agent-card.txt. The port is replaced by PORT so the file does not change from run to run.
GET /.well-known/agent-card.json

{
  "name": "Glossary Agent",
  "description": "Defines Java terms",
  "version": "1.0.0",
  "capabilities": {
    "streaming": true,
    "pushNotifications": false,
    "extendedAgentCard": false
  },
  "defaultInputModes": [
    "text/plain"
  ],
  "defaultOutputModes": [
    "text/plain"
  ],
  "skills": [
    {
      "id": "define",
      "name": "Define a term",
      "description": "Returns a one-sentence definition",
      "tags": [
        "java",
        "glossary"
      ],
      "examples": [
        "record"
      ]
    }
  ],
  "supportedInterfaces": [
    {
      "protocolBinding": "JSONRPC",
      "url": "http://127.0.0.1:PORT",
      "tenant": "",
      "protocolVersion": "1.0"
    }
  ]
}
Read it as a contract. capabilities.streaming tells a client it may ask for streamed updates. skills lists what the agent offers, with example inputs. supportedInterfaces says how to reach it: the binding is JSONRPC and the protocol version is 1.0. A client fetches the card first and builds its connection from it, which is why the second agent later needs only the Glossary Agent’s base URL.
Going deeper: what the card does not give you
The card above is unsigned and the endpoint is open. The card here declares extendedAgentCard: false and no security schemes, because I did not test authentication. In anything you expose beyond localhost you would add that first. The card path and the field names are what the 1.0 SDK produced; I did not compare them against the specification text line by line.

One message in, one task out

The wire format is JSON-RPC 2.0 over HTTP. To ask for a definition you send one SendMessage call whose message has a role and some parts. This is the raw request from the test in A2aTest.java, with no A2A client library involved:
            String request = "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"SendMessage\",\"params\":{\"message\":"
                    + "{\"messageId\":\"m-1\",\"role\":\"ROLE_USER\",\"parts\":[{\"text\":\"record\"}]},"
                    + "\"configuration\":{\"blocking\":true}}}";
Here is what went in and what came back, from 02-raw-send-message.txt:
POST /
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "m-1",
      "role": "ROLE_USER",
      "parts": [
        {
          "text": "record"
        }
      ]
    },
    "configuration": {
      "blocking": true
    }
  }
}

Response:
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "task": {
      "id": "<id-1>",
      "contextId": "<id-2>",
      "status": {
        "state": "TASK_STATE_COMPLETED",
        "timestamp": "<time>"
      },
      "artifacts": [
        {
          "artifactId": "definition",
          "name": "definition",
          "description": "",
          "parts": [
            {
              "text": "A record is a final class that is a transparent",
              "metadata": {},
              "filename": "",
              "mediaType": ""
            },
            {
              "text": " carrier for its components.",
              "metadata": {},
              "filename": "",
              "mediaType": ""
            }
          ],
          "metadata": {},
          "extensions": []
        }
      ],
      "history": [],
      "metadata": {}
    }
  }
}
The answer is not a string. It is a task: it has an id, a context id, a status with a state (TASK_STATE_COMPLETED), and the output as an artifact made of parts. The definition came back in two parts because the agent wrote it in two chunks, which matters in the streaming section. The task is the unit everything else in the protocol hangs off: you can ask about it later, cancel it, or continue it. Here is the agent behind that response. It is a single AgentExecutor with an execute method; the SDK turns what the agent emits into the task states you saw. From GlossaryAgent.java:
    public void execute(RequestContext context, AgentEmitter emitter) throws A2AError {
        if (context.getTask() == null) {
            emitter.submit();
        }
        String term = context.getUserInput(" ").strip().toLowerCase();
        if (term.isEmpty()) {
            emitter.requiresInput(emitter.newAgentMessage(List.<Part<?>>of(new TextPart("Which term should I define?")), Map.of()));
            return;
        }
        emitter.startWork();
        String definition = TERMS.get(term);
        if (definition == null) {
            emitter.fail(emitter.newAgentMessage(List.<Part<?>>of(new TextPart("I do not know the term '" + term + "'.")), Map.of()));
            return;
        }
        // Two chunks of one artifact, so a streaming client sees the answer arrive in pieces.
        int cut = definition.indexOf(' ', definition.length() / 2);
        emitter.addArtifact(List.<Part<?>>of(new TextPart(definition.substring(0, cut))), "definition", "definition", Map.of(), false, false);
        emitter.addArtifact(List.<Part<?>>of(new TextPart(definition.substring(cut))), "definition", "definition", Map.of(), true, true);
        emitter.complete();
    }
The agent never builds a task by hand. It says submit(), startWork(), adds artifact chunks, and calls complete() or fail(...). Each call becomes a status or artifact event that is stored on the task and sent to whoever is listening.
A small thing I observed in the alpha. I sent the request id as the string "1" and the response carried "id": 1, a number. A client that compares ids as strings would not match its own request. The test asserts the number form so the transcript stays honest, but treat it as an alpha quirk rather than a feature, and check it again when you upgrade the SDK.
Going deeper: the wire names that differ from older examples
If you have read A2A articles from before 1.0, some names here will look wrong. In the 1.0 SDK the JSON-RPC methods are SendMessage and SendStreamingMessage (not message/send), the role is ROLE_USER, task states are written TASK_STATE_*, and the card path is /.well-known/agent-card.json. The transcripts are generated from the running 1.0.0.Alpha3 code, so they are the reference for this article. The 0.3 and 1.0 protocol versions are not backward compatible.

Does the call wait for the agent?

A task can take minutes. A2A lets the caller choose whether SendMessage waits. The test makes an agent that accepts the task at once and then works for 400 ms, and sends the call three ways. The agent, from A2aTest.java:
                e.submit();
                e.startWork();
                try {
                    Thread.sleep(400);
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                }
                e.addArtifact(java.util.List.<io.a2a.spec.Part<?>>of(new io.a2a.spec.TextPart("slow answer")), "answer", "answer", java.util.Map.of(), false, true);
                e.complete();
The result is in 07-blocking.txt:
The agent accepts the task at once, then works for 400 ms before finishing.

SendMessage with configuration.blocking = false: task state TASK_STATE_WORKING, returned in under 400 ms: true
SendMessage with configuration.blocking = true: task state TASK_STATE_COMPLETED, returned in at least 400 ms: true
SendMessage with no configuration at all: task state TASK_STATE_WORKING, returned in under 400 ms: true
With blocking: true the call returns only when the task is finished. With blocking: false it returns straight away with the task submitted or working. The third line is the one that surprised me: leaving the configuration out behaves like false in this SDK, so a bare request returns a task that is not done yet. My first raw test did exactly that and passed only when the agent happened to finish quickly, which made it flaky until I set blocking explicitly.
Why the agent says submit() before it works. My first version of this test slept before the agent emitted anything, and then even blocking: false took the whole 400 ms. A non-blocking call returns at the first event the agent produces, so an agent that does slow work before submit() makes every call slow. Emit the task first, then work.
Going deeper: getting the result of a non-blocking call
A non-blocking caller gets a task id and then has to come back for the outcome: either by calling GetTask, or by subscribing to the task. The next section shows GetTask after a streamed call. I did not test SubscribeToTask on its own or push notifications, where the agent calls a webhook of yours when the task changes; the server code in this module routes SubscribeToTask but no test exercises it.

Streaming shows the lifecycle, and the task outlives the call

Now the same question through the SDK’s client, once without streaming and once with. The client side is in A2aCaller.java. It reads the card, builds a JSON-RPC client from it, and collects every event until the task reaches a state where the caller has to act:
        Client client = Client.builder(card)
                .withTransport(JSONRPCTransport.class, new JSONRPCTransportConfigBuilder())
                .clientConfig(ClientConfig.builder().setStreaming(streaming).build())
                .addConsumer((event, c) -> {
                    events.add(event);
                    if (event instanceof TaskEvent te) {
                        task.set(te.getTask());
                        if (stops(te.getTask().status().state())) { seenFinal.set(true); settled.countDown(); }
                    } else if (event instanceof TaskUpdateEvent tu) {
                        task.set(tu.getTask());
                        if (stops(tu.getTask().status().state())) { seenFinal.set(true); settled.countDown(); }
                    } else if (event instanceof MessageEvent me) {
                        message.set(me.getMessage());
                        seenFinal.set(true);
                        settled.countDown();
                    }
                })
                .streamingErrorHandler(t -> {
                    error.set(t);
                    settled.countDown();
                })
                .build();
        try {
            Message.Builder m = Message.builder().role(Message.Role.ROLE_USER)
                    .parts(new TextPart(text)).messageId(UUID.randomUUID().toString());
            if (continueTaskId != null) {
                m.taskId(continueTaskId);
            }
            client.sendMessage(m.build());
The transcript is 03-streaming.txt.
Same question, "record", asked twice.

Blocking (streaming off): 1 event
  TaskEvent         state=COMPLETED artifacts=1
  answer: A record is a final class that is a transparent carrier for its components.

Streaming: 5 events
  TaskStatusUpdate  state=SUBMITTED final=false
  TaskStatusUpdate  state=WORKING final=false
  TaskArtifactUpdate append=false lastChunk=false text="A record is a final class that is a transparent"
  TaskArtifactUpdate append=true lastChunk=true text=" carrier for its components."
  TaskStatusUpdate  state=COMPLETED final=true
  answer: A record is a final class that is a transparent carrier for its components.

GetTask for the streamed task afterwards: state TASK_STATE_COMPLETED, artifacts 1, artifact text "A record is a final class that is a transparent carrier for its components."
The non-streaming call returns one event: the finished task. The streaming call returns five, and they spell out the lifecycle: submitted, working, the first artifact chunk, the second chunk marked as an append and the last, and finally the completed status. A user interface can show the text as it arrives. Both end with the same answer. The last line shows the task still exists after the call ended: a separate GetTask request fetched it, completed, with the whole artifact.
Going deeper: a race in the client I had to handle
When the client sees a final state on a streaming call, it cancels its own HTTP request. That cancellation is reported to the streaming error handler as IOException: Request cancelled. In roughly one run in seven the error arrived and I treated it as a failure even though the answer was already complete. The fix in A2aCaller is to remember that a final state was seen and ignore an error that arrives after it. After the fix the module’s tests passed 25 runs in a row. That is evidence, not a proof; the race is in the client library and I did not look for the root cause there.

An agent can ask for more, and the same task carries on

Not every request is complete. If the message has no term, the Glossary Agent does not guess. It moves the task to INPUT_REQUIRED with a question. The caller answers by sending a new message that carries the same task id. The transcript is 04-input-required.txt.
Turn 1: message with no term.
  task <id-1> state INPUT_REQUIRED
  agent says: Which term should I define?

Turn 2: message "sealed class" sent with taskId <id-1>.
  task <id-1> state COMPLETED
  answer: A sealed class restricts which other classes may extend it.

Same task id both turns: true
The second message did not start a new task. It continued the first, which is why the last line says the ids match. This is the part of A2A that a plain request/response API does not give you: a conversation about one piece of work, with the state held by the agent that owns it.

Two Java agents: one is the server and the client

Callersends one taskBrief Agentserver and clientGlossary Agentone task per term
Now the interesting setup. A second agent, the Brief Agent, takes a comma-separated list of terms and returns a bulleted brief. It does not know any definitions. For each term it calls the Glossary Agent over A2A. So it is a server to its own caller and a client of the Glossary Agent at the same time. From BriefAgent.java:
        for (String term : context.getUserInput(" ").split(",")) {
            term = term.strip();
            try {
                A2aCaller.Reply reply = A2aCaller.send(glossaryUrl, term, null, false);
                TaskState state = reply.task().status().state();
                callLog.add("SendMessage \"" + term + "\" -> " + A2aCaller.short_(state) + " \"" + reply.text() + "\"");
                if (state != TaskState.TASK_STATE_COMPLETED) {
                    emitter.fail(emitter.newAgentMessage(
                            List.<Part<?>>of(new TextPart("Glossary Agent could not help: " + reply.text())), Map.of()));
                    return;
                }
                lines.add("- " + term + ": " + reply.text());
            } catch (Exception e) {
                emitter.fail(emitter.newAgentMessage(List.<Part<?>>of(new TextPart("Glossary Agent unreachable: " + e.getMessage())), Map.of()));
                return;
            }
        }
        emitter.addArtifact(List.<Part<?>>of(new TextPart(String.join("\n", lines))), "brief", "brief", Map.of(), false, true);
        emitter.complete();
    }
The test starts both agents, asks the Brief Agent for record, sealed class, then for record, monad, where the Glossary Agent has no entry for monad. It also records each call the Brief Agent made. The transcript is 05-agent-to-agent.txt.
Caller -> Brief Agent -> Glossary Agent.

Request: "record, sealed class"
  Brief Agent task state: COMPLETED
  result:
    - record: A record is a final class that is a transparent carrier for its components.
    - sealed class: A sealed class restricts which other classes may extend it.
  Calls the Brief Agent made to the Glossary Agent:
    SendMessage "record" -> COMPLETED "A record is a final class that is a transparent carrier for its components."
    SendMessage "sealed class" -> COMPLETED "A sealed class restricts which other classes may extend it."

Request: "record, monad"
  Brief Agent task state: FAILED
  message: Glossary Agent could not help: I do not know the term 'monad'.
  Calls the Brief Agent made to the Glossary Agent:
    SendMessage "record" -> COMPLETED "A record is a final class that is a transparent carrier for its components."
    SendMessage "monad" -> FAILED "I do not know the term 'monad'."
In the first request the Brief Agent made two calls and combined two answers. In the second the Glossary Agent’s task ended in FAILED, and the Brief Agent turned that into its own failed task with a message that says whose problem it was. The caller of the Brief Agent never talked to the Glossary Agent and does not need to know it exists.
Going deeper: what this does not handle
The Brief Agent calls the Glossary Agent one term at a time, waits for each, and stops at the first failure. It does not retry, run calls in parallel, cancel the first call if the caller cancels, or protect itself against a slow remote agent beyond the client’s 20 second wait. Those are the parts you would add before depending on it, and I did not test any of them.

A2A and MCP: different directions

A2A is often presented as a rival to MCP. The A2A project’s own page on the subject says they are complements, and puts it as: MCP is vertical and deepens a single agent, while A2A is horizontal and connects agents across that boundary. Put another way, A2A connects agents to each other, and MCP connects each agent to its own tools. I wrote the MCP server and client articles earlier (MCP server over streamable HTTP in Spring AI 2.0 and the MCP client), so the table below sets what I ran in this article against how MCP is used there. The A2A column comes from the transcripts above; the MCP column is the general shape of the protocol, not something re-measured here.
QuestionA2A (this article)MCP
Who talks to whomAn agent to another agent, as peersAn agent (or app) to the tools and data it uses
What you ask forA piece of work; you get a task with a stateA capability to be called; you get its result
How you find out what is offeredAn agent card at a well-known path (transcript 01)The client lists a server’s tools, resources and prompts
Work that takes timePart of the model: blocking or not, streaming, GetTask later (transcripts 03, 07)A call that returns a result
Asking the caller for moreINPUT_REQUIRED on the same task (transcript 04)Not modelled as a task state
The other side isOpaque: you do not see its tools or promptsA set of functions whose inputs you can see
They are not exclusive. The A2A docs illustrate this with an auto-repair-shop example, in which agents collaborate over A2A while each uses MCP for its own tools. The Glossary Agent here could do the same: be reached over A2A, and call a database or a search tool over MCP behind the scenes.

The glue I had to write

The SDK’s server side expects Quarkus: it has a ready-made class that exposes the endpoints and starts what it needs through CDI. I wanted something you can read in one sitting and run with mvn test, so I hosted the SDK’s request handler on the JDK’s HttpServer. The routing is a port of the SDK’s Quarkus route class: it parses the JSON-RPC body, calls the matching handler, and writes either a JSON response or a server-sent-events stream. The wiring, from A2aHttpServer.java:
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        MainEventBus bus = new MainEventBus();
        InMemoryTaskStore taskStore = new InMemoryTaskStore();
        InMemoryQueueManager queues = new InMemoryQueueManager(taskStore, bus);
        InMemoryPushNotificationConfigStore pushConfigs = new InMemoryPushNotificationConfigStore();
        MainEventBusProcessor processor = new MainEventBusProcessor(bus, taskStore, new BasePushNotificationSender(pushConfigs), queues);
        callPackagePrivate(processor, "start");
        DefaultRequestHandler handler = DefaultRequestHandler.create(agent, taskStore, queues, pushConfigs, processor, executor, executor);
        JSONRPCHandler rpc = new JSONRPCHandler(card, handler, executor);
Two things were needed that the documentation I read did not mention, and both are workarounds rather than good practice:
Reflection on a package-private method. Outside CDI, nothing starts the SDK’s event-bus thread. The public ensureStarted() on MainEventBusProcessor does nothing in this setup, so every event was accepted and none was delivered, and the client got “Could not find a Task/Message”. The method that actually starts the thread is package-private, so the server calls it by reflection. This is the kind of thing that breaks on the next SDK release without warning.
    private static void callPackagePrivate(MainEventBusProcessor processor, String method) {
        try {
            var m = MainEventBusProcessor.class.getDeclaredMethod(method);
            m.setAccessible(true);
            m.invoke(processor);
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException(e);
        }
    }
The second is a one-class service registration: the SDK refuses an agent card that names a transport it cannot discover, and the Quarkus module normally registers one. JdkJsonRpcTransportMetadata registers JSON-RPC through META-INF/services.
Going deeper: alternatives I did not try
If you are on Spring Boot, a community project, spring-ai-a2a-server-autoconfigure (group org.springaicommunity, version 0.3.0 when I looked), provides auto-configuration for an A2A server. I did not run it, so I cannot say how it compares; its published dependencies pointed at Spring Boot 4.0.6 and Spring AI 2.0.0-RC1, a little behind the versions used elsewhere in this series. Quarkus is the SDK’s first-class server runtime, so if you can use it, you avoid all of the glue above. The dependency tree of this module is in 06-dependencies.txt: the SDK brings protobuf, Gson and the Jakarta CDI API, but not Quarkus.

Should you even do this?

A fair answer. If your agents live in one codebase and one team owns them, you do not need a network protocol between them: call a method, or use a workflow library. A2A pays off when the agents are owned by different teams or run as separate services, when they may be written in different languages, and when you want callers to discover what an agent does from a card instead of from a shared jar. The cost today is real: the 1.0 SDK for Java is an alpha, the stable one speaks the older protocol, the server side wants Quarkus, and the pieces I had to work around may change. What this article did not test: any language model behind an agent; authentication and signed cards; push notifications; the gRPC and REST bindings; SubscribeToTask as its own flow; cancelling a running task; many concurrent tasks or any load; and the community Spring Boot starter. The tests show the protocol working between two Java agents on one machine, not how it behaves across a network or at scale.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.