Transport Layers

A2A supports four transport bindings: JSON-RPC 2.0, REST, WebSocket (websocket feature flag), and gRPC (grpc feature flag). All four are first-class citizens in a2a-rust — the server can serve multiple transports simultaneously, and ClientBuilder::from_card selects an interface from the agent card (build() constructs JSON-RPC or REST; gRPC needs build_grpc, and WebSocket a transport passed to with_custom_transport).

JSON-RPC 2.0

The JSON-RPC transport sends all requests to a single endpoint as POST requests with a JSON-RPC 2.0 envelope:

{
  "jsonrpc": "2.0",
  "method": "SendMessage",
  "id": "req-1",
  "params": {
    "message": {
      "messageId": "msg-1",
      "role": "ROLE_USER",
      "parts": [{"text": "Hello, agent!"}]
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": "req-1",
  "result": {
    "task": {
      "id": "task-abc",
      "contextId": "ctx-123",
      "status": { "state": "TASK_STATE_COMPLETED" },
      "artifacts": [...]
    }
  }
}

Method Names

A2A OperationJSON-RPC Method
Send messageSendMessage
Stream messageSendStreamingMessage
Get taskGetTask
List tasksListTasks
Cancel taskCancelTask
Subscribe to taskSubscribeToTask
Create push configCreateTaskPushNotificationConfig
Get push configGetTaskPushNotificationConfig
List push configsListTaskPushNotificationConfigs
Delete push configDeleteTaskPushNotificationConfig
Extended cardGetExtendedAgentCard

Batching

JSON-RPC supports batch requests — multiple operations in a single HTTP request:

[
  {"jsonrpc": "2.0", "method": "GetTask", "id": "1", "params": {"id": "task-a"}},
  {"jsonrpc": "2.0", "method": "GetTask", "id": "2", "params": {"id": "task-b"}}
]

Note: Streaming methods (SendStreamingMessage, SubscribeToTask) cannot be used in batch requests and will return an error.

ID Handling

JSON-RPC request IDs can be strings, numbers (including 0 and floats), or null. The server preserves the exact ID type in the response.

REST

The REST transport uses standard HTTP methods and URL paths:

OperationMethodPath
Send messagePOST/message:send
Stream messagePOST/message:stream
Get taskGET/tasks/{id}
List tasksGET/tasks
Cancel taskPOST/tasks/{id}:cancel
SubscribeGET|POST/tasks/{id}:subscribe
Create push configPOST/tasks/{id}/pushNotificationConfigs
Get push configGET/tasks/{id}/pushNotificationConfigs/{configId}
List push configsGET/tasks/{id}/pushNotificationConfigs
Delete push configDELETE/tasks/{id}/pushNotificationConfigs/{configId}
Extended cardGET/extendedAgentCard
Agent cardGET/.well-known/agent-card.json

Multi-Tenant Paths

The spec's proto binds every method twice: a primary pattern with no tenant in the path (/tasks/{id}) and an additional_bindings pattern with the tenant as the leading segment (/{tenant}/tasks/{id}). This SDK's client sends the prefix form — the same one the official Python SDK's REST client sends, and the only one the official Python server reads — with the segment percent-encoded, and keeps the field in POST bodies as well.

The server accepts the tenant from every place the proto lets it arrive, in this order of precedence:

  1. the path prefix — /{tenant}/… (canonical) or this SDK's explicit /tenants/{tenant-id}/… form, percent-decoded;
  2. the ?tenant= query parameter on GET and DELETE (§11.5's transcoding of the primary pattern);
  3. the tenant field of a POST body (message:send, message:stream, tasks/{id}:cancel, tasks/{id}:subscribe, push-config create).

A path tenant is injected into a POST body that omits it, so POST /acme/message:send with no tenant in the JSON lands in acme. When the path and the body disagree the path wins, as a path variable does under google.api.http.

Content Types

The REST dispatcher accepts both application/json and application/a2a+json. A POST with any other Content-Type is refused with 400.

Responses are application/json on both HTTP bindings. For HTTP+JSON this is a deliberate deviation from §11.1's SHOULD of application/a2a+json, because a2a-go's client cannot read an error labelled that way; JSON-RPC's application/json is what §9.1 requires.

Security

The REST dispatcher includes built-in protections:

  • Path traversal rejection — .. in path segments (including percent-encoded %2E%2E) returns 400
  • Query string limits — Query strings over 4 KiB return 414
  • Body size limits — Request bodies over 4 MiB return 413

WebSocket

The WebSocket transport (websocket feature flag) provides a persistent bidirectional channel over a single TCP connection. JSON-RPC 2.0 messages are exchanged as WebSocket text frames.

# Server
a2a-protocol-server = { version = "0.14", features = ["websocket"] }

# Client
a2a-protocol-client = { version = "0.14", features = ["websocket"] }

Server

use a2a_protocol_sdk::prelude::agent_executor;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let my_executor = MyAgent;
use a2a_protocol_server::{WebSocketDispatcher, RequestHandlerBuilder};
use std::sync::Arc;

let handler = Arc::new(RequestHandlerBuilder::new(my_executor).build().unwrap());
let dispatcher = Arc::new(WebSocketDispatcher::new(handler));

// Start accepting WebSocket connections
dispatcher.serve("0.0.0.0:3002").await?;
Ok(())
}

Protocol

  • Client sends JSON-RPC 2.0 requests as text frames
  • Server responds with JSON-RPC 2.0 responses as text frames
  • The v1.0 method names the JSON-RPC HTTP binding routes; of the v0.3 method/verb aliases only message/stream is accepted, and the others are refused with MethodNotFound
  • For streaming methods (SendStreamingMessage, SubscribeToTask), the server sends multiple frames — one per event — followed by a stream_complete response
  • Ping/pong frames are handled automatically
  • Connection closes cleanly on WebSocket close frame
  • The upgrade request's HTTP headers reach the handler for every request on the connection, so authentication and tenant resolution work exactly as over HTTP; supply credentials at connect time (WebSocketTransport::connect_with_config)

Client

use a2a_protocol_sdk::client::ClientBuilder;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use std::time::Duration;
use a2a_protocol_client::{WebSocketTransport, WebSocketTransportConfig};

// Defaults are fine for a trusted agent on a local network:
let transport = WebSocketTransport::connect("ws://agent.example.com:3002").await?;

// Or set the bounds explicitly — see the table below for what each one covers:
let transport = WebSocketTransport::connect_with_config(
    "wss://agent.example.com:3002",
    WebSocketTransportConfig::default()
        .with_connect_timeout(Duration::from_secs(5))   // TCP + TLS + upgrade
        .with_request_timeout(Duration::from_secs(30))  // per-request response wait
        .with_max_message_size(4 * 1024 * 1024),        // incoming frame ceiling
)
.await?;

let client = ClientBuilder::new("wss://agent.example.com:3002")
    .with_custom_transport(transport)
    .build()?;
Ok(())
}

Because the transport is built before the client and handed to with_custom_transport, it never sees the ClientConfig — so with_timeout, with_connection_timeout and with_max_response_size on the builder do not reach it. Its equivalents are these:

KnobDefaultBounds
connect_timeout10sthe whole handshake — a server that accepts TCP and never upgrades
request_timeout30swaiting for one response on an established connection
max_message_size32 MiBan incoming frame, at the protocol level
max_pending_requests64requests awaiting a response on one connection; the next is refused with ClientError::TooManyPendingRequests (retryable) rather than queued

After the socket drops, the next call reconnects to the same endpoint with the same configuration (one reconnect shared by concurrent callers, bounded by connect_timeout). A stream keeps its connection open until you drop it, even if the client is dropped first. A stream you read more slowly than the agent writes ends with a stream_lagged error once 64 frames are waiting unread (ClientError::is_stream_lagged); resubscribe to continue.

When to Use WebSocket

  • Long-lived connections — Avoids TCP/TLS handshake overhead per request
  • Bidirectional streaming — Server can push events without SSE
  • Low latency — No HTTP framing overhead for small messages

gRPC

The gRPC transport (grpc feature flag) provides high-performance RPC via protocol buffers and HTTP/2. As of 0.7 it is protobuf-native: the transport speaks the canonical lf.a2a.v1.A2AService service with fully-typed messages generated from the A2A specification's protobuf schema, making it wire-compatible with the official Go, Python, and Java SDKs. (Through 0.6 it tunneled JSON inside a protobuf bytes field on a non-standard service. The tunnel client was removed in 0.7; the tunnel service and the grpc-legacy-json feature that served it alongside the canonical one were removed in 0.8. A 0.6 client must now upgrade rather than be tunneled for.)

# Server
a2a-protocol-server = { version = "0.14", features = ["grpc"] }

# Client
a2a-protocol-client = { version = "0.14", features = ["grpc"] }

Server

use a2a_protocol_sdk::prelude::*;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let my_executor = MyAgent;
use a2a_protocol_server::{GrpcDispatcher, GrpcConfig};
use std::sync::Arc;

let handler = Arc::new(RequestHandlerBuilder::new(my_executor).build().unwrap());
let config = GrpcConfig::default()
    .with_max_message_size(8 * 1024 * 1024);
let dispatcher = GrpcDispatcher::new(handler, config);
dispatcher.serve("0.0.0.0:50051").await?;
Ok(())
}

Tip: Use serve_with_listener() when you need to know the server address before constructing the handler (e.g., for agent cards with correct URLs). Pre-bind a TcpListener, extract the address, build your handler, then pass the listener.

Client

An Agent Card's gRPC interface advertises a gRPC target — host:port, per the proto's AgentInterface.url comment — not a URL, because gRPC names carry no scheme. GrpcTransport::connect and ClientBuilder::build_grpc accept that form, and decide how to dial it with a GrpcBareAddressScheme:

PolicyBare host:port is dialled…When
HttpsExceptLoopback (default)with TLS, except localhost / 127.0.0.0/8 / ::1 in plaintextthe spec requires TLS in production (§13); a loopback peer is this machine
Httpsalways with TLSa loopback TLS terminator, or to remove the exception
Httpalways in plaintexta private network whose agents advertise agent:50051 and terminate TLS in a mesh or sidecar

An address that already carries http:// or https:// is used as-is. https:// — explicit or chosen by the policy — needs the grpc-tls feature; without it the connect fails with a message naming the feature rather than attempting a plaintext handshake against a TLS port.

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::AgentCard;
async fn f(card: AgentCard, ca_pem: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
use a2a_protocol_client::{ClientBuilder, GrpcBareAddressScheme};

// From a card whose gRPC interface says "grpc.example.com:443": TLS,
// verified against the bundled Mozilla roots (needs `grpc-tls`).
let client = ClientBuilder::from_card(&card)?.build_grpc().await?;

// A Compose network where the card says "analyzer:50051" and the mesh
// terminates TLS: plaintext, on purpose.
let client = ClientBuilder::from_card(&card)?
    .with_grpc_bare_address_scheme(GrpcBareAddressScheme::Http)
    .build_grpc()
    .await?;

// A private CA, pinned (needs `grpc-tls`). The TLS types are re-exported
// from `transport::grpc`, so no tonic dependency of your own.
use a2a_protocol_client::transport::grpc::{Certificate, ClientTlsConfig};

let tls = ClientTlsConfig::new()
    .ca_certificate(Certificate::from_pem(ca_pem))
    .domain_name("agent.internal");
let client = ClientBuilder::from_card(&card)?
    .with_grpc_tls_config(tls)
    .build_grpc()
    .await?;
Ok(())
}
}

Serving TLS

The server's gRPC listener is plaintext with the grpc feature — the shape the official SDKs' gRPC servers expect, with TLS terminated in a proxy or mesh. With grpc-tls on a2a-protocol-server it can serve TLS itself: GrpcDispatcher::with_tls takes a ServerTlsConfig carrying the server certificate and key and, for mutual TLS, the CA that client certificates must chain to. The types are re-exported from dispatch::grpc, so no tonic dependency of your own. The listener then speaks TLS only; a plaintext client is refused at the handshake rather than served.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use a2a_protocol_server::RequestHandler;
use a2a_protocol_server::dispatch::grpc::{
    Certificate, GrpcConfig, GrpcDispatcher, Identity, ServerTlsConfig,
};

async fn example(
    handler: Arc<RequestHandler>,
    cert_pem: &str,
    key_pem: &str,
    client_ca_pem: &str,
) -> std::io::Result<()> {
let tls = ServerTlsConfig::new()
    .identity(Identity::from_pem(cert_pem, key_pem))
    // Mutual TLS: clients must present a certificate this CA signed.
    // Add `.client_auth_optional(true)` to admit clients that present none.
    .client_ca_root(Certificate::from_pem(client_ca_pem));

GrpcDispatcher::new(handler, GrpcConfig::default())
    .with_tls(tls)
    .serve("0.0.0.0:50051")
    .await?;
Ok(())
}
}

A configuration tonic rejects — a key that does not match its certificate — comes back from the serve* call as an std::io::Error, not a panic. tonic builds its acceptor from the process-level rustls crypto provider; when none is installed and more than one is linked, the dispatcher installs ring rather than letting rustls panic, and never overrides a provider the application installed first.

Protocol

  • All 11 A2A methods are mapped to gRPC RPCs on the canonical lf.a2a.v1.A2AService
  • Streaming methods (SendStreamingMessage, SubscribeToTask) use gRPC server streaming
  • Messages are the fully-typed lf.a2a.v1 protobuf types, converted to/from the serde domain types via a bidirectional TryFrom layer (ProtoJSON semantics)
  • The canonical schema lives at proto/a2a_v1/a2a.proto, kept byte-identical to the specification copy

When to Use gRPC

  • Service mesh integration — gRPC is native to Kubernetes, Istio, Envoy
  • Language interop — gRPC has code generation for 10+ languages
  • HTTP/2 multiplexing — Multiple RPCs over a single connection
  • Streaming — Native server streaming without SSE

Choosing a Transport

FactorJSON-RPCRESTWebSocketgRPC
Batch operationsSupportedNot supportedNot supportedNot supported
CachingLimited (POST-only)HTTP cache-friendly (GET)Not applicableNot applicable
ToolingNeeds JSON-RPC clientStandard HTTP tools workWebSocket client neededgRPC client needed
URL structureSingle endpointResource-orientedSingle connectionSingle connection
StreamingSSE via POSTSSE via POST/GETNative text framesNative server streaming
Connection reuseHTTP keep-aliveHTTP keep-alivePersistent connectionHTTP/2 multiplexing

JSON-RPC and REST use SSE for streaming. WebSocket uses native text frames. gRPC uses native server streaming over HTTP/2. The choice is mostly about ecosystem fit — JSON-RPC for agent-to-agent communication, REST for standard HTTP tooling, WebSocket for persistent low-latency connections, gRPC for service mesh and cross-language interop.

Running Both Transports

The server can serve both transports simultaneously on different ports:

use a2a_protocol_sdk::prelude::agent_executor;
struct MyAgent;
agent_executor!(MyAgent, |_ctx, _queue| async { Ok(()) });
fn main() {
let my_executor = MyAgent;
use a2a_protocol_sdk::server::{JsonRpcDispatcher, RestDispatcher, RequestHandlerBuilder};
use std::sync::Arc;

let handler = Arc::new(
    RequestHandlerBuilder::new(my_executor).build().unwrap()
);

// JSON-RPC on port 3000
let jsonrpc = Arc::new(JsonRpcDispatcher::new(Arc::clone(&handler)));

// REST on port 3001
let rest = Arc::new(RestDispatcher::new(handler));
}

All dispatchers share the same RequestHandler, which means they share the same task store, push config store, and executor.

Next Steps