Delegating work across agent services with Agent2Agent
How discovery, stateful tasks, artifact handoffs, and caller-side acceptance work across separate agent runtimes, and how I adapted that model in Chatterbox.
This post is AI augmented.
I used LLMs for research and some writing help, since English isn’t my first language. Still, I personally guide and edit the writing because I don’t want to publish AI slop.
This blog is for my own understanding and to look back on later. It’s a place to write down my projects and research related to things I’m working on.
Before shipping a release, a deployment agent may need an independent code-review agent service to inspect commit a1b2c3d. When those two agents run as separate services, the reviewer can keep its prompt, memory, and internal tools opaque, and the caller does not need access to them to coordinate the work: it sends the request across the network and evaluates what comes back. Delegating that review means finding the remote service, packaging the diff and test logs, answering any mid-run questions, and deciding whether the returned review-report clears the release.
Google introduced the Agent2Agent (A2A) protocol with partners on April 9, 2025 to standardize this kind of cross-service handoff. Across the A2A specification and its core concepts, a client agent and an opaque remote server coordinate through capability metadata, multi-part messages, stateful tasks, and output artifacts without sharing internal state or tool access. Following one release review across the wire shows how those protocol mechanics fit together, and clarifies what A2A leaves to the caller: tracking report revisions across follow-up runs, handling ambiguous network retries or best-effort cancellations, and deciding whether a completed report is accepted.
Discovering capabilities and endpoints with Agent Cards
To hand off commit a1b2c3d, the release agent first needs to locate the review service and check whether it accepts diffs and returns structured reports. In A2A's agent discovery model, every server publishes an Agent Card, a JSON manifest that describes the service's identity and version, advertised skills, supported input and output media types, authentication schemes, and optional capabilities such as streaming, push notifications, protocol extensions, or an authenticated extended card. Depending on how the environment is deployed, a client can fetch this manifest from the well-known URI https://{server_domain}/.well-known/agent-card.json, look it up in a curated catalog or registry, or read it from local configuration.
Inside the Agent Card, the supportedInterfaces list declares the server's endpoints in preference order, pairing each target URL with a protocol version and transport binding. The specification defines three standard bindings that map the same abstract operations and Protocol Buffers data model onto concrete transports: JSON-RPC 2.0 over HTTP(S), gRPC over HTTP/2, and RESTful HTTP+JSON. The release agent reads supportedInterfaces from the top down, selects the first transport binding it supports, and sends the target A2A-Version parameter on its requests.
If a service hides restricted skills or rate limits from unauthenticated callers, it can advertise extended-card support in the public manifest and serve a fuller Agent Card after the client authenticates. Even when fetched over HTTPS and authenticated, however, an Agent Card only states what the remote service advertises and how to format calls to it; the card does not audit the remote agent's implementation or grant the review service access to the caller's repository.
Tracking requests, pauses, and outputs across Tasks
Once the release agent selects an interface from the Agent Card, it starts the review by calling SendMessage with a Message object carrying a client-generated messageId, a user role, and one or more Part entries. Each Part holds one content type (plain text, inline bytes, a file URL, or structured JSON data) along with optional media-type, filename, and metadata fields. For commit a1b2c3d, the release agent sends a Message whose parts contain the review instructions, the Git diff, and the test log. As explained in Life of a Task, the server can answer a self-contained exchange with a stateless Message or create a stateful Task with a server-generated taskId and contextId. When a task is created, the request configuration's returnImmediately flag lets the caller choose whether SendMessage waits until the task pauses or reaches a terminal state, or returns the in-progress Task immediately for asynchronous tracking.
While inspecting commit a1b2c3d, the review agent finds that a migration test references a fixture file omitted from the initial request. Rather than failing the run, the server transitions the Task to the interrupted input-required state (TASK_STATE_INPUT_REQUIRED) and attaches a status Message asking for the missing file. To supply that fixture and resume the paused run, the release agent sends a follow-up Message carrying the fixture Part and the existing taskId. When a client sends both taskId and contextId, the server verifies that they match and rejects mismatched identifiers; when the client sends only taskId, the server infers the contextId from the active task.
That server-generated contextId groups related tasks and messages into one logical conversation so the remote service can maintain its own internal session state across turns, without creating shared model memory between the two runtimes. After receiving the fixture and finishing its analysis of a1b2c3d, the review service moves the task to the completed state (TASK_STATE_COMPLETED). To keep conversational turns separate from deliverables, A2A recommends returning substantive outputs as Artifact objects rather than messages, though Task.artifacts remains optional in the data model. Each Artifact carries a required artifactId, one or more Part entries, and an optional human-readable name. In this example, the completed first task returns an Artifact named review-report that flags a race condition in the diff.
Once a Task reaches a terminal state (completed, failed, canceled, or rejected), it is immutable and cannot restart or accept further messages; sending another Message to that taskId returns an unsupported-operation error. After the release side fixes the race condition in commit e4f5a6b, the release agent cannot reopen the first task to verify the patch. Instead, it sends a new Message that omits taskId, keeps the same contextId, and lists the first task's ID inside referenceTaskIds. The review agent creates a second Task within that contextId, inspects commit e4f5a6b, and finishes with a new Artifact that reuses the optional name review-report under a fresh artifactId.
Reusing the review-report name across both runs helps the client recognize that the second output refines the first, while each immutable Task ties its returned artifacts to a distinct unit of work alongside whichever conversational messages the server persists in task history. The A2A server does not track artifact version chains or decide whether the second report clears the release bar. In the protocol state machine, a completed task records only that the server finished executing that unit of work; linking the e4f5a6b report to the earlier a1b2c3d report and deciding whether to accept the findings remain client-side policy.
Following in-flight work with polling, streams, and webhooks
When a review task runs asynchronously, the client needs a way to learn when the task pauses, emits output chunks, or finishes. A2A defines three update mechanisms: polling with GetTask, streaming with SendStreamingMessage or SubscribeToTask, and outbound webhooks via push notifications. Polling with GetTask is supported by every A2A server and protocol binding, making it a dependable baseline when updates are infrequent or the client sits behind a firewall. Streaming and push notifications are optional capabilities; a client must check the server's Agent Card before calling those operations, or the server will reject the call as unsupported.
When streaming is enabled, SendStreamingMessage opens an ordered stream over Server-Sent Events in the HTTP bindings or server streaming in gRPC. For a task stream, the first event is always a Task snapshot of the current state, followed by status and artifact update events (which can stream large deliverables incrementally across chunks), closing once the task reaches a terminal state. If a client started a task without streaming or lost its connection mid-run, it can attach to any non-terminal task with SubscribeToTask, which likewise emits a Task object representing current task state at subscription time before streaming subsequent updates.
A stream is only a delivery channel, and its lifecycle is separate from the task's execution lifecycle. A server can broadcast the same ordered events to multiple concurrent streams for one taskId, and dropping a client connection does not stop the task. At the same time, streamed status updates are not a durable audit log: the server decides which conversational messages to store in task history, and transient progress messages missed while a stream was disconnected are not guaranteed to replay when the client reconnects or calls GetTask to fetch the current state and artifacts.
For long-running jobs where keeping a stream open is impractical, the client can register a webhook callback URL when sending the message or through a push notification config. As task updates occur, the server sends HTTP POST requests to that callback URL containing a StreamResponse JSON payload, which carries one of a current task snapshot, a message, a status update, or an artifact update. Webhook security divides into protocol requirements and recommended hardening: the server must attach the configured authentication credentials and should enforce request timeouts, retry failed deliveries with backoff, and validate callback URLs against server-side request forgery (SSRF) by rejecting localhost and private IP ranges. On the receiving end, the client must verify those credentials, confirm that taskId matches an expected task, and return an HTTP 2xx status code, and it should expose the webhook over HTTPS and process deliveries idempotently in case retries produce duplicate posts.
Transport security, mid-task auth, and cancellation limits
Because an A2A handoff crosses a service boundary, the protocol relies on standard web and enterprise security practices at the transport layer rather than embedding identity inside message payloads. Production traffic must use encrypted connections (HTTPS for HTTP bindings and TLS for gRPC). The client reads the required authentication schemes from the Agent Card (such as OAuth 2.0, OpenID Connect, API keys, HTTP bearer tokens, or mutual TLS), obtains credentials out of band, and sends them in HTTP headers or gRPC metadata on every request. The server authenticates each call and scopes task reads, listings, cancellations, and subscriptions to that caller's authorization boundary. A taskId is never an authorization capability on its own: if a caller requests a task that does not exist or belongs to another principal, GetTask returns the same TaskNotFoundError without revealing whether the task exists.
When a running task needs an extra credential or human approval to reach a downstream resource, such as pulling restricted production traces during a review, the server transitions the task to the auth-required state (TASK_STATE_AUTH_REQUIRED) with a status Message explaining what is needed. Unless an extension or prior agreement defines an in-band credential flow, the client fulfills this request out of band over a secure channel. While waiting for out-of-band credentials, the server should keep any active response streams open and may continue processing as soon as the credential arrives without requiring a follow-up Message, so a client without an open stream should subscribe, register a webhook, or poll to catch the update. The specification also bounds the scope of this pause: transitioning to auth-required only signals that authorization is needed, while the credential issuer, extension, or agent implementation remains responsible for defining and checking what operations that credential actually permits.
Network retries and cancellations require the same care on the client side. Read operations like GetTask and task listings are idempotent, and CancelTask is idempotent as well, whereas SendMessage deduplication is optional: a server may inspect messageId to ignore duplicate submissions, or it may start a second run if a client blindly retries after a dropped connection. If the release agent decides to abort a review because commit a1b2c3d was superseded, calling CancelTask is a best-effort request for the server to stop the ongoing task. The task may have already completed or failed, or reached a stage that cannot be interrupted, causing the server to return a non-cancelable error. Even when the server stops execution and moves the task to the canceled state, the A2A protocol provides no rollback guarantee for external side effects that have already occurred, leaving the caller responsible for handling any completed side effects or duplicate runs.
Where A2A peer delegation meets MCP tool calls
In a system that already uses the Model Context Protocol (MCP), the release workflow clarifies where MCP stops and where A2A and MCP complement each other. When preparing its review request, the release agent uses MCP internally to read local Git diffs and test logs; when inspecting the patch, the review agent uses MCP internally to run static analysis checks and query trace storage. Neither service exposes its internal MCP servers, tool schemas, or credentials to the other. Each agent keeps its tool layer private and exchanges only Agent Card metadata, Message parts, Task states, and Artifact outputs across the service boundary.
It is inaccurate to describe A2A simply as the asynchronous version of MCP. Under the optional negotiated MCP Tasks extension, a client can declare task capability when invoking a tool, and the MCP server can choose on a per-request basis to return an asynchronous task handle instead of an immediate tool result. That MCP task handle supports polling and status notifications, can pause in input_required when the server needs client-side elicitation or sampling input, and accepts cooperative cancellation before delivering the tool call's eventual result.
Because both protocols can represent long-running work, pause in input_required, and handle cooperative cancellation, the real distinction is what sits on the other side of the call. An MCP request invokes a specific tool or resource with structured arguments inside one agent's capability layer, and even with the Tasks extension the server decides on each eligible call whether to materialize a task handle. An A2A request delegates a goal to a separate, opaque agent service that advertises skills through an Agent Card, groups multi-task workflows under a shared contextId, links follow-up runs through referenceTaskIds, and decides internally which plans, models, and MCP tools to use.
Borrowing the task and report model in Chatterbox
Chatterbox is the shared workspace and coordination tool I use with my agents across hosts. Because those agents already operate inside one authenticated workspace, Chatterbox does not publish an Agent Card, negotiate transport bindings, or expose A2A wire endpoints. What I borrowed into its deployed request-and-report workflow is A2A's separation between a bounded task, a mid-run clarification pause, an immutable output report, and a linked follow-up request. When a requester agent delegates a check on an existing Board ticket to another active peer, the request stores the instructions, an optional expected report name, any same-ticket prior request references, attachments, and the source ticket revision. Only the designated responder can start the request; if it pauses in input_required to ask a question, supplying the answer records the reply without automatically resuming the run, so the responder explicitly starts the request again when it picks up the work.
The responder can complete a request only from the working state, submitting a report name, a summary, and Markdown content, which increments the per-ticket version for that report name, locks the report body as immutable, and sets its review status to pending. While review is pending, the requester, an independent current ticket assignee, or I can record a single review decision marking the report accepted or requesting changes; requesting changes leaves the request's execution state completed, and any follow-up check after a fix opens a new request on the same ticket referencing the earlier one. Cancellation and workspace state stay similarly separate: a requester canceling an unstarted request closes it immediately, whereas requester or operator cancellation of a started request records cancellation intent until the responder confirms cancellation or returns already-finished work. Neither completing nor canceling a request kills a host process, closes the parent Board ticket, or releases a cooperative resource claim.