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:
| Condition | Answer |
|---|---|
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.
| Name | Holds |
|---|---|
:path | the upgrade request's path (read by PathSegmentTenantResolver) |
:peer-addr | the address of the connection's peer (CallContext::peer_addr) |
:peer-certificate | the client's mutual-TLS certificate, DER in standard base64, when the listener serves TLS and the client presented one |
:upgrade-traceparent, :upgrade-tracestate | the 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 type | On the wire |
|---|---|
| Field names | lowerCamelCase (contextId, messageId) |
| Enumerations | the proto enum name as a string (TASK_STATE_WORKING, ROLE_USER) |
google.protobuf.Timestamp | RFC 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 error | Code |
|---|---|
| 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:
| Refusal | reason | metadata | HTTP equivalent |
|---|---|---|---|
| No usable credential: absent, malformed, expired or unknown | UNAUTHENTICATED | www-authenticate: the challenge, naming the scheme | 401 + WWW-Authenticate |
| A valid credential that does not permit the operation | PERMISSION_DENIED | none | 403 |
{"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(arustls::ServerConfig) orwith_tls_pem(both need thetls-rustlsfeature ofa2a-protocol-server, which is off by default there and on ina2a-protocol-sdk's defaults) makes the listenerwss://only, offering ALPNhttp/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 publishwss://in the card. The SDK's client connects towss://with the roots its HTTPS transports use (featuretls-rustls, on by default), or a configuration given toWebSocketTransportConfig::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_originsnames the origins a browser may connect from; any other is refused with403and the error body of §1 (PERMISSION_DENIED) at the handshake. Origins are compared whole, ignoring ASCII case. An upgrade with noOrigin(every non-browser client, this SDK's included) is still accepted unlessrequire_originis set. The check stops browsers only: any other client can send whateverOriginit 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 HTTP | WebSocket | |
|---|---|---|
| Service parameters | per request | per connection (upgrade headers) |
| Streams | SSE, one HTTP response each | frames on the shared connection, ended by stream_complete |
| Resumption | Last-Event-ID | none; resubscribe |
| Refused credential | 401 + WWW-Authenticate / 403 | -32600 with an ErrorInfo (UNAUTHENTICATED + www-authenticate, or PERMISSION_DENIED) |
| Batches | supported | not supported (-32600) |
| Binary payload frames | not applicable | refused with -32700 |
The TCK's --equivalence mode runs the same scenarios over this binding and
the three specification bindings and compares the outcomes.