WebSocket binding specification

The WebSocket binding carries the JSON-RPC binding's messages over one WebSocket connection. It is a custom protocol binding in the sense of A2A §12: the server is WebSocketDispatcher (feature websocket), the client is WebSocketTransport. This page is its specification; the dispatcher guide covers setup.

protocolBinding: https://a2a-rust.com/bindings/websocket/v1
url:             ws://host:port/   (wss:// with with_tls, or behind a TLS terminator)
constant:        a2a_protocol_types::WEBSOCKET_BINDING_URI

1. Connection

A client opens the connection with an HTTP/1.1 upgrade request (RFC 6455). The server answers 101 Switching Protocols, or refuses the upgrade:

ConditionAnswer
A2A-Version absent or empty (read as 0.3, §3.6.2)400, unless the server was built with accept_missing_version_header()
A2A-Version names a version other than 1.0 (a patch number is ignored)400
Origin not on the allowlist, when one is set; or absent, when require_origin() is set (§7)403, checked before the version
Handshake not finished within the handshake timeout (default 10 s)connection closed

A refused upgrade carries the HTTP+JSON binding's error body (§11.6): {"error":{"code":400,"status":"FAILED_PRECONDITION","message":…,"details":[ErrorInfo]}}. The Origin refusal carries the same shape with "code":403, "status":"PERMISSION_DENIED" and no details.

No subprotocol is negotiated: the SDK's client sends no Sec-WebSocket-Protocol and the server's answer selects none.

2. Service parameters (§3.2.6, §12.3)

Transmission. Service parameters are the HTTP headers of the upgrade request. They are captured once, at the handshake, and apply to every request on the connection.

Fallback. A browser cannot set headers on a WebSocket upgrade, so the version may instead be sent as the A2A-Version query parameter of the upgrade URL (§3.6.1), for example ws://host/?A2A-Version=1.0. A header wins over the query parameter. No other parameter has a query-string fallback.

Value constraints. Header names are lowercased. A header line whose value is not visible ASCII (printable ASCII, space and tab) is left out, as on the HTTP bindings; that includes valid UTF-8 outside ASCII, so X-Name: café is not seen. A header sent on several lines is one parameter, its remaining lines joined in order with ", " ("; " for Cookie). Last-Event-ID is dropped: this binding has no resumption, and the upgrade's headers reach every call on the connection.

Reserved names. The server adds entries of its own, each under a name starting with a colon. A colon cannot start an HTTP/1 header name, so no client header can collide with one.

NameHolds
:paththe upgrade request's path (read by PathSegmentTenantResolver)
:peer-addrthe address of the connection's peer (CallContext::peer_addr)
:peer-certificatethe client's mutual-TLS certificate, DER in standard base64, when the listener serves TLS and the client presented one
:upgrade-traceparent, :upgrade-tracestatethe upgrade request's traceparent and tracestate, moved here so that calls on the connection are not children of the request that opened it; each call's span links to that trace instead

Consequence. A parameter cannot vary per request. A client that needs a different A2A-Extensions value or credential for one call opens another connection.

3. Messages

Every message is one JSON-RPC 2.0 object in one text frame, in both directions. Method names, params and results are those of the JSON-RPC binding (§9): the eleven v1.0 PascalCase methods. The v0.3 method/verb names are answered -32601 (MethodNotFound), as over HTTP. JSON-RPC batches are not supported: an array is answered -32600.

{"jsonrpc":"2.0","id":"1","method":"GetTask","params":{"id":"task-1"}}
{"jsonrpc":"2.0","id":"1","result":{"id":"task-1","contextId":"ctx-1","status":{"state":"TASK_STATE_WORKING","timestamp":"2026-10-12T08:00:00Z"}}}

A connection may have up to 64 requests in progress at once; a stream counts as in progress until it ends. Their responses can arrive in any order; a client correlates them by id.

A request with no id member is a notification (JSON-RPC 2.0 §4.1): the server runs it and sends no frame for it, whether it succeeds or fails, except that a notification read while 64 requests are in progress is not run and is refused with the -32603 frame of §5 and a null id. A streaming method (SendStreamingMessage, SubscribeToTask) sent as a notification is not run: a stream is its only answer.

Data types (§12.2)

The payloads are the JSON-RPC binding's ProtoJSON (§9), unchanged:

Proto typeOn the wire
Field nameslowerCamelCase (contextId, messageId)
Enumerationsthe proto enum name as a string (TASK_STATE_WORKING, ROLE_USER)
google.protobuf.TimestampRFC 3339 string in UTC (2026-10-12T08:00:00Z); the server converts an executor's other offsets to UTC
bytes (a part's raw)base64 string
google.protobuf.Struct (metadata, a part's data)JSON object

Binary WebSocket frames are not part of the binding. The server answers one with -32700 and "id": null.

4. Streaming (§12.5)

Mechanism. SendStreamingMessage and SubscribeToTask answer with a sequence of frames on the same connection. Each frame is a JSON-RPC success response carrying the request's id, whose result is one StreamResponse (task, message, statusUpdate or artifactUpdate).

Ordering. One stream's events arrive in the order the server produced them. Frames of different requests on one connection may interleave.

Termination. After the last event the server sends one more frame for the same id:

{"jsonrpc":"2.0","id":"7","result":{"status":"stream_complete"}}

This frame is a transport marker, not a StreamResponse. A stream that fails ends with a JSON-RPC error frame for its id instead, and no marker.

Reconnection. There is no resumption. The HTTP binding's Last-Event-ID has no counterpart here, and the server ignores one on the upgrade request, so a client whose connection drops opens a new one and calls SubscribeToTask. That stream's first event is the task's current snapshot, from which the client continues; events in between are not replayed.

5. Errors (§5.4, §12.4)

Errors are JSON-RPC error objects carrying the JSON-RPC binding's codes and data, an array of typed details (google.rpc.ErrorInfo for A2A errors, RetryInfo when a retry delay applies, BadRequest for params that did not parse):

{"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"Task not found: task-9","data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"TASK_NOT_FOUND","domain":"a2a-protocol.org"}]}}
A2A errorCode
Frame is not JSON-32700
Frame is JSON but not a request-32600
Method not found, or a v0.3 name-32601
Invalid params-32602 (with BadRequest when the params did not parse)
Internal error, or more than 64 requests in progress on the connection-32603
TaskNotFound-32001
TaskNotCancelable-32002
PushNotificationNotSupported-32003
UnsupportedOperation-32004
ContentTypeNotSupported-32005
InvalidAgentResponse-32006
ExtendedAgentCardNotConfigured-32007
ExtensionSupportRequired-32008
VersionNotSupported-32009

A refused credential is -32600 with the refusal detail of §6. A mid-stream error keeps its own code and details, including the streamLagged marker of a subscriber that fell behind.

6. Authentication (§12.6)

Credentials travel in the upgrade request's headers (Authorization, X-API-Key, or whatever the agent card's security schemes name) and are checked by the server's interceptors on every request. Because the connection is already upgraded, a refused credential cannot carry an HTTP status or a WWW-Authenticate header, so the body carries both (A2A §12.6, "Handle Challenges"). It is answered -32600 whose data holds a google.rpc.ErrorInfo from this binding's domain, a2a-rust.com:

RefusalreasonmetadataHTTP equivalent
No usable credential: absent, malformed, expired or unknownUNAUTHENTICATEDwww-authenticate: the challenge, naming the scheme401 + WWW-Authenticate
A valid credential that does not permit the operationPERMISSION_DENIEDnone403
{"jsonrpc":"2.0","id":"1","error":{"code":-32600,"message":"authentication required","data":[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"UNAUTHENTICATED","domain":"a2a-rust.com","metadata":{"www-authenticate":"Bearer realm=\"a2a\""}}]}}

Other details the refusal carries come before it. The SDK's client reports the two as ClientError::UnexpectedStatus with status 401 and 403, as it reports them on the other bindings, on a unary call and as a stream's first item. A credential is fixed for the connection, so a client recovers from an UNAUTHENTICATED by opening a new one with a new credential.

Browsers cannot set Authorization on a WebSocket upgrade. This binding offers no in-band alternative; a browser client authenticates at a gateway that accepts its cookie or ticket and forwards a header.

7. Security considerations (§13.4)

  • Transport. The dispatcher listens on plain ws:// unless given a certificate: WebSocketDispatcher::with_tls (a rustls::ServerConfig) or with_tls_pem (both need the tls-rustls feature of a2a-protocol-server, which is off by default there and on in a2a-protocol-sdk's defaults) makes the listener wss:// only, offering ALPN http/1.1, with the TLS handshake bound by the handshake timeout. The agent card is served over the same TLS session. Production deployments (spec §7.1) either do that or put a TLS terminator in front, and publish wss:// in the card. The SDK's client connects to wss:// with the roots its HTTPS transports use (feature tls-rustls, on by default), or a configuration given to WebSocketTransportConfig::with_tls_config.
  • Origin. By default the server accepts an upgrade from any Origin. Where credentials are ambient (a cookie a gateway turns into a header), a page on another site could open an authenticated connection (cross-site WebSocket hijacking). WebSocketDispatcher::with_allowed_origins names the origins a browser may connect from; any other is refused with 403 and the error body of §1 (PERMISSION_DENIED) at the handshake. Origins are compared whole, ignoring ASCII case. An upgrade with no Origin (every non-browser client, this SDK's included) is still accepted unless require_origin is set. The check stops browsers only: any other client can send whatever Origin it likes, so it does not replace authentication.
  • One identity per connection. Credentials are read from the upgrade, so every request on a connection acts as one caller. The interceptors check them on every request, so a token that expires is refused from then on, but it cannot be replaced without opening a new connection.
  • Limits. Incoming messages are capped at 4 MiB (a larger one ends the connection), 64 requests per connection, a handshake timeout, and optional caps on connections and idle time; see the dispatcher guide.

8. Differences from the JSON-RPC HTTP binding

JSON-RPC over HTTPWebSocket
Service parametersper requestper connection (upgrade headers)
StreamsSSE, one HTTP response eachframes on the shared connection, ended by stream_complete
ResumptionLast-Event-IDnone; resubscribe
Refused credential401 + WWW-Authenticate / 403-32600 with an ErrorInfo (UNAUTHENTICATED + www-authenticate, or PERMISSION_DENIED)
Batchessupportednot supported (-32600)
Binary payload framesnot applicablerefused with -32700

The TCK's --equivalence mode runs the same scenarios over this binding and the three specification bindings and compares the outcomes.