A2A protocol
Expose a Runner as a Google Agent-to-Agent JSON-RPC endpoint and call remote A2A agents as local BaseAgents.
A2A is Google’s Agent-to-Agent protocol: JSON-RPC 2.0 over HTTP with SSE streaming, agent-card discovery, and a task lifecycle. The a2a feature ships both halves — a server bridge that exposes any local Runner as an A2A endpoint, and RemoteA2aAgent, a BaseAgent that proxies to a remote A2A server.
Serving a Runner over A2A
The server side is configured with an AgentCard (what the agent advertises), an A2aServerConfig (paths and auth), and an A2aState (the runner plus task persistence and webhook delivery).
use adk_rs::a2a::{
A2aServerConfig, A2aState, AgentCapabilities, AgentCard, serve,
};
use std::sync::Arc;
let card = AgentCard {
name: "greeter".into(),
description: "A friendly greeter".into(),
url: "https://my-host/a2a/".into(),
version: "0.1.0".into(),
capabilities: AgentCapabilities { streaming: true, ..Default::default() },
default_input_modes: vec!["text/plain".into()],
default_output_modes: vec!["text/plain".into()],
..Default::default()
};
let state = A2aState::new(runner, A2aServerConfig::new(card).with_bearer_token("hunter2"));
serve("127.0.0.1:8080".parse()?, state).await?;The AgentCard carries name, description, url (the JSON-RPC endpoint), version, optional provider / documentation_url / authentication, capabilities (streaming, push_notifications, state_transition_history), default_input_modes / default_output_modes (MIME types), and a list of AgentSkills. It is served at /.well-known/agent.json by default — the path is configurable via A2aServerConfig::agent_card_path, and the JSON-RPC mount path via rpc_path (default /). A2aState forces capabilities.push_notifications = true on the served card, because the bridge always supports webhooks.
fn A2aServerConfig::new(agent_card: AgentCard) -> Self- Defaults: card at
/.well-known/agent.json, RPC at/, no auth. fn with_bearer_token(self, token: impl Into<String>) -> Self- Require
Authorization: Bearer <token>on every request (constant-time comparison). fn A2aState::new(runner: Arc<Runner>, cfg: A2aServerConfig) -> Self- Uses the default
InMemoryTaskServicefor task persistence. fn A2aState::with_task_service(runner, cfg, tasks: Arc<dyn TaskService>) -> Self- Plug in your own Redis / SQL / Firestore task backend.
fn router(state: A2aState) -> axum::Router- Build the router for embedding into an existing axum app.
async fn serve(addr: SocketAddr, state: A2aState) -> Result<()>- Bind and serve. Refuses non-loopback binds without a bearer token.
async fn serve_with(addr, state, opts: ServeOptions) -> Result<()>ServeOptions { dangerously_allow_unauthenticated_remote }opts out of the bind guard, mirroring the HTTP server.
JSON-RPC methods
| Method | Behaviour |
|---|---|
message/send | Synchronous: create a task, run the agent to completion, return the final Task with accumulated history and artifacts. |
message/stream | Same input, but the response is an SSE channel: an initial Task snapshot (so the caller has the id), then every TaskStatusUpdateEvent / TaskArtifactUpdateEvent, closed by a done SSE event. |
tasks/get | Look up a Task by id; historyLength trims the returned history. |
tasks/cancel | Flip the runner’s cancellation token for the task’s invocation, then mark the task canceled. Terminal tasks return -32002. |
tasks/resubscribe | Re-attach to a task’s SSE channel: a current Task snapshot, then live updates until a final status. |
tasks/pushNotificationConfig/set | Register a webhook for a task. Plaintext public http:// URLs are refused with -32602 (HTTPS-or-loopback rule). |
tasks/pushNotificationConfig/get | Retrieve a config by pushNotificationConfigId (or the first one when omitted). |
tasks/pushNotificationConfig/list | Enumerate every config registered for a task. |
tasks/pushNotificationConfig/delete | Remove one config — or all of them when the id is omitted. Returns {"removed": n}. |
Errors use the JSON-RPC reserved range plus two A2A-specific codes: -32001 (TASK_NOT_FOUND) and -32002 (TASK_NOT_CANCELABLE). If an agent pauses awaiting user input — a tool confirmation, an auth consent, or a long-running tool result — the task ends in input-required rather than completed.
Task lifecycle and TaskService
TaskState is a closed set: submitted, working, input-required, completed, canceled, failed, rejected, auth-required, unknown — with is_terminal() true for completed / canceled / failed / rejected. A message/* call creates the task as submitted, transitions it to working before the agent starts, and finishes with completed, input-required, or failed.
Persistence is the TaskService trait: create_task, get_task, update_status (which broadcasts a TaskStatusUpdateEvent to all SSE subscribers), append_history, append_artifact, subscribe, cancel_task, and the four *_push_config methods. The shipped InMemoryTaskService stores tasks behind a mutex and fans updates out via per-task tokio::sync::broadcast channels; implement the trait directly for an external store.
Push notifications
PushNotifier (one per A2aState) delivers updates out-of-band: for each registered config it spawns a subscriber that POSTs every status / artifact update to the webhook URL. Delivery is best-effort and fire-and-forget — failures are logged, retried with a small backoff (3 attempts total, i.e. up to 2 retries), and never surfaced to the inbound caller. Webhook bodies match the message/stream envelope ({"jsonrpc": "2.0", "result": <update>}), so a receiver written for SSE consumes them unchanged. A configured token is sent as Authorization: Bearer <token> on each POST, and webhook URLs must be HTTPS or loopback.
Calling a remote agent
use adk_rs::a2a::{RemoteA2aAgent, RemoteA2aConfig};
use std::time::Duration;
let remote = RemoteA2aAgent::connect(RemoteA2aConfig {
name: "fallback".into(), // overridden by the fetched agent card
url: "https://peer.example.com/a2a/".into(),
agent_card_url: Some("https://peer.example.com/.well-known/agent.json".into()),
stream: true, // use message/stream over SSE
timeout: Duration::from_secs(60),
..Default::default()
})
.await?;RemoteA2aConfig has seven fields: name and description (local fallbacks), url (the JSON-RPC endpoint), agent_card_url (when Some, connect fetches the card and adopts its name and description; RemoteA2aAgent::new skips discovery entirely), headers (extra HTTP headers such as Authorization), timeout (default 120 s), and stream (use message/stream SSE instead of synchronous message/send; default false).
Because RemoteA2aAgent implements BaseAgent, it slots into agent trees exactly like a local LlmAgent — sub_agent(remote), AgentTool::new(remote), or as a step in a workflow agent. On each turn it converts the invocation’s user content into an A2A Message (carrying the session id as contextId and the user id in metadata), dispatches it, and converts the resulting Task history or streamed updates back into ADK events. message_send and message_stream are also public for direct protocol use.
Cancellation routes back to the runner
When the bridge starts a task, it records the runner’s invocation_id in the task’s metadata under adk:invocationId. A later tasks/cancel looks that id up and calls Runner::cancel first, so the in-flight agent observes the cooperative cancellation token and stops cleanly before the task is marked canceled.
- Runner —
start,cancel, and the invocation registry the bridge drives. - Cancellation & resume — what happens inside the agent on cancel.
- Security model — the HTTPS-or-loopback and bind guards A2A shares with the rest of the crate.