Skip to content
SJ
All writing
11 min read

A2A Is Not a Better Function Call

Reading A2A as RPC with extra steps is the mistake. It is built for a callee that is opaque, long-running, and not yours — and that assumption is what should change your design.

A2AAgentsAIProtocolsArchitecture

Most introductions to A2A — Agent2Agent, published by Google in April 2025 and since moved under the Linux Foundation — spend their time on the wire format. JSON-RPC over HTTP, a few method names, a well-known URL. Read that way, it looks like RPC with extra ceremony, and the reasonable engineering reaction is to ask why an HTTP endpoint would not do.

The wire format is the least interesting part. What matters is the set of assumptions the protocol is built on, because those assumptions are what should change the shape of your code: the thing you are calling is not yours, will not tell you how it works, runs on its own credentials, and may take ten minutes to answer. Every awkward-looking piece of A2A follows from one of those four facts.

The gap it actually fills

There are two different integration problems in an agent system, and they get conflated constantly.

The first is vertical: one agent needs access to tools, files and data. That is what MCP addresses — it is the wiring between a model and the capabilities you hand it. The tools are yours, they are in your trust boundary, and you decide exactly what each one does.

The second is horizontal: your agent needs another agent to do something, and that agent belongs to a different team, a different vendor, or a different company. You do not get to see its prompt, its model, its memory, or the tools it calls. You get a description of what it can do and, eventually, a result.

These compose rather than compete. A realistic service uses MCP inward for its own tools and speaks A2A outward to peers:

  your agent ──MCP──▶ your database, your search index, your APIs
      │
      └──A2A──▶ someone else's agent ──MCP──▶ their tools (invisible to you)

The moment both sides are yours, in one repository, with a shared deployment — this is a function call, and a function call is better. Protocols exist to survive boundaries. If there is no boundary, all you have bought is serialisation overhead and a new failure mode.

Discovery: the agent card

An A2A server publishes a JSON document describing itself, served from a well-known path — /.well-known/agent-card.json in current revisions, /.well-known/agent.json in early ones, so check both when consuming a card in the wild.

{
  "protocolVersion": "0.3.0",
  "name": "Freight Quote Agent",
  "description": "Quotes and books freight across our carrier network.",
  "url": "https://agents.example.com/a2a",
  "version": "1.4.0",
  "capabilities": { "streaming": true, "pushNotifications": true },
  "defaultInputModes": ["text/plain", "application/json"],
  "defaultOutputModes": ["text/plain", "application/json"],
  "skills": [
    {
      "id": "quote-shipment",
      "name": "Quote a shipment",
      "description": "Given origin, destination, weight and dimensions, returns
                      priced options with transit times. Does not book.",
      "tags": ["logistics", "pricing"],
      "examples": ["Quote 3 pallets, Pune to Rotterdam, 400kg, by sea"]
    }
  ],
  "securitySchemes": { ... }
}

Two things about this document are easy to get wrong.

First, it is prompt material, not documentation. The consumer of a skill description is usually a model deciding whether to delegate. Vague descriptions produce wrong delegations in exactly the way vague tool descriptions produce wrong tool calls. Say what the skill does, what it needs, and — the part everyone omits — what it explicitly does not do. “Does not book” in the example above prevents a whole category of misuse for six words.

Second, a card you fetched is untrusted input. If your agent discovers peers dynamically and feeds their descriptions into a model's context, you have handed a remote party a writable region of your prompt. That is the ordinary prompt injection situation, with the additional wrinkle that the text arrived from a host you do not control. In practice: pin the peers you talk to, treat card contents as data rather than instructions, and never let a fetched description expand what your agent is permitted to do.

The task is the unit

The central object in A2A is not a request. It is a task with an identity and a lifecycle:

submitted ──▶ working ──┬──▶ completed
                        ├──▶ input-required ──▶ working ──▶ ...
                        ├──▶ failed
                        ├──▶ canceled
                        └──▶ rejected

You start one by sending a message:

POST /a2a
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "message/send",
  "params": {
    "message": {
      "role": "user",
      "messageId": "b41c...",
      "parts": [
        { "kind": "text", "text": "Quote 3 pallets, Pune to Rotterdam, 400kg" }
      ]
    }
  }
}

What comes back is a task object carrying an id, a status, and whatever the agent has produced so far. That id is the handle for everything afterwards — polling with tasks/get, cancelling with tasks/cancel, or continuing the same piece of work by sending another message that references it.

The state worth designing around is input-required. A remote agent is allowed to stop halfway and ask you something:

{
  "id": "t_9f2a",
  "status": {
    "state": "input-required",
    "message": {
      "role": "agent",
      "parts": [{ "kind": "text",
                  "text": "Sea or air? Sea adds 21 days and saves 60%." }]
    }
  }
}

This has no analogue in a normal API call, and it is the single thing most likely to break a client written by someone who read A2A as RPC. Your code cannot model a call as “send, await, done”. It has to be able to receive a question, route it — to a model, or to a human — and resume the same task rather than starting a new one. If your client treats input-required as an error, you will silently lose every interaction that needed one clarification, which in my experience is a lot of them.

Two payload types come back, and the distinction is worth internalising. Messages are the conversation — questions, status commentary, reasoning the agent chose to share. Artifacts are the deliverables — the quote document, the generated file, the structured result. Both are built from typed parts: text, file, or structured data. If you persist everything as one blob of text you lose the ability to tell the thing you asked for from the chatter around it.

Opacity is the point

A2A deliberately does not expose the remote agent's internals. No shared memory, no visibility into its tools, no access to its plan or its model. You see declared skills and returned results. Nothing else.

This is not an oversight to be worked around; it is the property that makes cross-organisation delegation possible at all. Nobody exposes their retrieval stack and system prompts to a partner. But it has consequences that need to show up in your design:

  • Everything the remote needs must be in the message. There is no shared context to lean on and no cheap way to pass a reference into your world. Payloads get verbose, and you should be deliberate about what leaves your boundary — the convenience of “just send the whole record” is how customer data ends up in a vendor's logs.
  • You cannot debug through the boundary. When a result is wrong, you have your request and their answer, and that is the entire evidence base. So log both, in full, keyed by task id, from day one. Retrofitting this after a disputed result is miserable.
  • Treat the peer as a vendor, not a subroutine. It has latency you do not control, availability you did not choose, and behaviour that changes when they redeploy a prompt. Timeouts, retries with idempotency, circuit breakers, and a defined behaviour for “the freight agent is down” are not optional extras — they are the integration.
  • Their output is untrusted input. A remote agent can be wrong, and a remote agent that has itself been injected can be adversarial. Validate structured results against your own schema before acting on them, and never let returned text decide what your own tools do.

Work that outlives the connection

Agent work is slow in a way HTTP request handling is not. A quote may involve three carrier lookups and a model deciding between them. The protocol offers three ways to deal with duration, and they are for different situations.

Streamingmessage/stream returns server-sent events with incremental status and artifact updates, so a user watching a screen sees progress. Useful when a human is present and the work takes seconds to a couple of minutes. It buys you responsive UI, not reliability: a dropped connection loses the stream, which is what tasks/resubscribe exists to repair.

Push notifications tasks/pushNotificationConfig/set registers a webhook the remote agent calls when the task changes state. This is the mechanism for work measured in minutes or hours, where nobody is holding a connection open. One rule matters here: the notification is a signal, not a payload. Verify it came from who it claims, then fetch the task yourself over an authenticated channel:

// wrong: the webhook body decides what happened
app.post("/a2a/notify", async (req, res) => {
  await bookFreight(req.body.task.artifacts[0]);   // attacker-authored input
  res.sendStatus(200);
});

// right: the webhook is a nudge; you go and read the authoritative state
app.post("/a2a/notify", async (req, res) => {
  await verifyNotificationSignature(req);          // throws if unverified
  res.sendStatus(202);                             // ack fast, work after
  const task = await peer.getTask(req.body.taskId); // authenticated fetch
  await handleTaskUpdate(task);
});

Pollingtasks/get on an interval. Least elegant, entirely adequate for batch work, and the only option when you cannot expose an inbound endpoint. Do not build webhooks for a nightly job.

Where authorisation lives

A2A does not invent an auth scheme. The agent card declares standard HTTP security schemes the way an OpenAPI document does, and the server enforces them. That part is ordinary. The part that is not ordinary is that there are two distinct questions hiding behind one call, and the protocol only answers the first:

  1. Is the calling agent allowed to ask? Answered by the credential on the request. This is service-to-service auth and it is well understood.
  2. Is the human behind the calling agent allowed to have this result? Not answered by anything in the protocol. If your agent authenticates as itself and forwards whatever its user asked, you have built a confused deputy with a network hop in the middle.

The failure looks mundane in a log. An internal assistant calls the freight agent with its service credential; a user who should only see their own region's shipments asks about another region; the freight agent, seeing a valid and broadly-scoped service identity, answers. No component malfunctioned. The authority was simply never scoped to the person.

So the discipline from the injection post carries across the boundary unchanged, with more reach: propagate a scoped, short-lived identity for the actual actor rather than a long-lived service token; have the receiving side enforce against that identity; and never let the content of a message be the thing that authorises the action it requests. An agent that says “the supervisor approved this” has told you a fact about its text, not about your permission model.

One more, specific to delegation: decide explicitly whether a peer may delegate onward. Chains form quickly, and a task you handed to one agent can end up executed by a third you have never heard of. If that is unacceptable for a given class of work, say so at the boundary and keep irreversible actions — payments, bookings, anything outbound — on your own side of it.

When you do not need this

Most systems currently reaching for agent-to-agent protocols do not need one. Honest list of cases where something simpler wins:

  • Both agents are yours, in one deployment. Call the function. Shared types, one stack trace, no serialisation.
  • One agent using tools. That is MCP's problem. Wrapping your own tools as peer agents adds a network boundary and buys nothing.
  • The interface is a stable schema. If the request is fully specified by fields you both agreed on, that is an HTTP API. An API is faster, cheaper, testable and does not involve a model deciding what you meant. The natural-language interface is a cost you pay when you need the flexibility, not a feature by itself.
  • You have a latency budget in the tens of milliseconds. Task lifecycles, streaming channels and model deliberation do not fit in a synchronous request path.
  • You have exactly one integration. A protocol is amortised across peers. For a single partner, a bespoke contract is usually less work — adopt the protocol when the third integration appears, or when you want to be discoverable by parties you have not met.

The conditions where it does earn its cost are specific:

  • The other side is genuinely opaque — different team, vendor or org.
  • The work is long-running and may need clarification mid-flight.
  • The interface is better stated in natural language than in a fixed schema, because the space of valid requests is open-ended.
  • You expect several peers over time, and want one client and one auth story rather than N bespoke ones.

The short version

A2A is not a nicer way to call a function. It is a set of assumptions about a callee that is opaque, slow, credentialed separately, and not yours — and every part of the protocol falls out of those. Publish an agent card written for a model rather than for a wiki, including what each skill refuses to do. Model interactions as tasks with identity, and make input-required a path your client actually handles. Use streaming for a watching human, webhooks for real duration, polling when neither justifies itself — and treat a webhook as a signal to go read authoritative state, never as the state. Log both sides of every exchange by task id, because when the boundary is opaque that log is the whole evidence base. Carry a scoped human identity across the hop and enforce it on arrival, since a delegating agent is a confused deputy with a longer reach than most. And before any of it, check whether the thing you are building has a boundary at all — if both agents are yours and in one process, the protocol is overhead wearing the costume of architecture.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.