agent-client-protocol-http exposes ACP agents over one /acp endpoint.
POST /acpwithinitializecreates a connection and returnsAcp-Connection-Id.- Later
POST /acprequests includeAcp-Connection-Id; session-scoped requests also includeAcp-Session-Idorparams.sessionId. GET /acpwithAccept: text/event-streamstreams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages.GET /acpwith a WebSocket upgrade uses text frames for JSON-RPC messages.DELETE /acptears down the connection.
POST /acp request bodies are limited to 16 MiB.
Ordinary transport diagnostics use byte lengths, fixed message kinds, stream scope, and fixed failure labels rather than message bodies. They do not format raw methods, request/session IDs, WebSocket close reasons, or transport error text, which can contain prompts, file/image content, or credential-bearing URLs. This does not change messages or errors delivered through the protocol.
Opt-in conductor recordings are different: they intentionally retain protocol payloads and must be treated as sensitive. This policy covers diagnostics emitted by the HTTP transport crate, not logs from applications or underlying libraries.
JSON-RPC batches are not supported. ACP protocol v2 removed them, and the SDK rejects them in both protocol versions:
- A
POST /acpbody that is a JSON array, including an empty one, gets400 Bad Requestwith a single JSON-RPC error body:-32600(Invalid Request) withid: null. None of its entries is forwarded, and no connection is created. - A WebSocket text frame that is a JSON array gets that error as one text frame, and none of its entries is forwarded.
HttpClientsends every message individually. A JSON array received from the server, in an SSE event or a POST response body, is treated as a malformed message.
If the agent emits a notification or callback before its initialize response is ready, the server buffers that frame for the connection's SSE stream until initialization completes. See Transport Architecture for the shared rules.
After initialize, clients should open a connection-level SSE stream:
GET /acpAccept: text/event-streamAcp-Connection-Id: <connection id>- no
Acp-Session-Id
This stream carries connection-scoped messages.
Session-scoped messages are routed to session-specific SSE streams. For each active session, clients should also open:
GET /acpAccept: text/event-streamAcp-Connection-Id: <connection id>Acp-Session-Id: <session id>
Open a session stream before sending methods such as session/prompt,
session/load, session/resume, or other session-scoped requests. When a
session/new or session/fork response returns a new sessionId, open an SSE
stream for that returned session before expecting updates or responses for it.
The server only keeps streams for sessions that exist on the connection. A
session exists once the agent names it: in a session/new or session/fork
result, in a successful response to a request scoped to that session (such as
session/load or session/resume), or in a request or notification the agent
sends for it. The server records a new session before it queues the response
that names it, so a stream opened right after that response finds it.
Sending a session-scoped request does not create a session stream. If the session has no stream when the agent answers, the response goes to the connection stream instead.
A client may open a stream for a session that does not exist yet, so it
receives the updates session/load replays. The server allows up to 16 such
unconfirmed session streams per connection and forgets each one when the client
closes it before the agent names the session. Beyond that limit, a GET for an
unknown Acp-Session-Id gets 404 Not Found. A successful session/close or
session/delete removes the session's stream. A client still reading it
receives the response and the stream stays open until the client closes it.
The crate does not enable either transport side by default. Opt into only the side(s) you need.
agent-client-protocol-http = { version = "5", features = ["client"] }
# For a server instead:
# agent-client-protocol-http = { version = "5", features = ["server"] }
# To enable both sides:
# agent-client-protocol-http = { version = "5", features = ["client", "server"] }The client feature exposes HttpClient. Its configure_http and
from_http_client hooks take reqwest types, and HttpClientError::Reqwest
holds a reqwest::Error, so add a matching reqwest dependency to customize
the HTTP client or inspect that error. The server feature exposes
AcpHttpServer, ServerOptions, and CorsOptions.
AcpHttpServer::into_router returns an axum::Router to mount in your axum
application, so your axum dependency must match this crate's.
Request cancellation is available through the core SDK:
agent-client-protocol-http = { version = "5", features = ["client", "server"] }$/cancel_request is connection-scoped. The HTTP transport does not apply
Acp-Session-Id to cancellation notifications, and routes outgoing
cancellation notifications over the connection stream rather than a session
stream.
WebSocket connections can carry cancellation at any point after the socket is
open. With HTTP + SSE, cancellation can be sent after initialize completes and
the client has received Acp-Connection-Id; an in-flight initialize request
cannot be cancelled with a hop-local $/cancel_request on this transport shape.
use agent_client_protocol_http::AcpHttpServer;
let app = AcpHttpServer::new(|| my_agent()).into_router();
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app).await?;Cross-origin browser access is disabled by default. Enable it by allowlisting the browser origins that should be able to access the ACP endpoint:
use agent_client_protocol_http::{AcpHttpServer, CorsOptions, ServerOptions};
let app = AcpHttpServer::new(|| my_agent())
.with_options(
ServerOptions::default()
.with_cors(CorsOptions::allow_origins(["http://localhost:5173"])?),
)
.into_router();ServerOptions is non-exhaustive. Start from ServerOptions::default() and use
with_path(...), with_cors(...), and with_health_endpoint(...); its fields
remain public for reading and mutation. Defaults remain /acp, disabled CORS,
and an enabled /health endpoint. See the config API migration
for replacing struct literals and update syntax.
CORS controls browser cross-origin access; it does not authenticate clients.
The WebSocket origin check accepts requests without an Origin header, so an
origin allowlist is not a substitute for authentication.
The built-in CORS policy allows ACP transport headers, but not Authorization,
and does not enable credentialed CORS (Access-Control-Allow-Credentials).
CorsOptions::allow_any_origin() is a wildcard origin policy, not an
authentication or credential policy.
Hosts that need bearer headers, cookies, or another credential policy should
enforce authentication on the router returned by into_router() and supply
their own CORS layer. Leave ServerOptions::cors disabled when providing that
HTTP CORS layer to avoid stacking conflicting policies. The separate WebSocket
origin check still uses CorsOptions; with the built-in policy disabled it
rejects upgrades carrying an Origin header. Browser WebSocket hosts can use
an explicit CorsOptions::allow_origins(...) allowlist, but a host needing a
different credentialed HTTP CORS policy and browser WebSocket origins on the
same endpoint needs additional host routing/policy integration beyond these
options. This SDK does not provide a general authentication configuration.
use agent_client_protocol_http::HttpClient;
let transport = HttpClient::new("http://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;The same HttpClient also speaks WebSocket — pass a ws:// or wss:// URL
and it will open a single bidirectional connection instead of using POST + SSE:
let transport = HttpClient::new("ws://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;The client validates the WebSocket handshake before sending any queued ACP
messages. This transport does not negotiate WebSocket subprotocols or extensions,
including compression. Setting Sec-WebSocket-Protocol or
Sec-WebSocket-Extensions in custom default headers does not enable support for
them; a server response selecting either is rejected.
Use the client builder to configure headers, proxies, DNS, TLS, and timeouts. The configuration is used for both HTTP/SSE requests and WebSocket handshakes:
use std::time::Duration;
use agent_client_protocol_http::HttpClient;
let transport = HttpClient::builder("wss://agent.example")
.configure_http(|http| {
http.user_agent("my-acp-client")
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(10))
})
.build()?;configure_http transforms the current reqwest::ClientBuilder; repeated calls
retain earlier configuration. Use it for options such as default_headers,
proxy, resolve, tls_certs_merge, and identity. It does not open a
connection. URL validation and HTTP-client construction errors are returned by
build(), before connecting the transport.
builder(base_url) uses the same path rule as new: append /acp unless the
path already ends with it, after removing trailing slashes. For an exact
endpoint, including a custom server path or query string, use
builder_with_endpoint(endpoint) instead. with_endpoint(endpoint) remains
the unconfigured convenience constructor.
For WebSocket URLs, the SDK applies HTTP/1.1 and disables redirects after the configuration callback. These are transport requirements, not defaults a callback can override. A redirect fails at the original endpoint without sending handshake headers to its destination; resolve the intended WebSocket endpoint before connecting. HTTP/SSE retains the caller's HTTP-version and redirect settings.
For WebSocket clients, do not set Host, Connection, Upgrade, or any
Sec-WebSocket-* header through reqwest::Proxy::headers. Use that API only
for non-handshake headers required by the proxy; proxy authentication can use
Proxy::basic_auth or custom_http_auth.
The SDK explicitly sets Connection, Upgrade, Sec-WebSocket-Version, and
Sec-WebSocket-Key over default_headers, but reqwest applies non-tunnel proxy
headers afterward on plain ws:// requests and can overwrite those values.
The callback's proxy configuration is opaque: the SDK cannot
inspect, sanitize, or reject those settings at build(). This is a caller
configuration constraint, not an enforced builder restriction. Upgrade response
validation still precedes ACP data; it does not prove that every request header
was unchanged. For example, replacing the random key causes a response computed
from the replacement key to fail accept validation, but other reserved-header
overrides are not necessarily detected. For wss://, proxy headers apply to the
CONNECT request rather than the tunneled WebSocket handshake.
Reqwest's connection timeout covers connection establishment. Its request/read timeouts also cover the opening WebSocket handshake but do not impose a lifetime or idle timeout on the upgraded socket. For HTTP/SSE, request/read timeouts retain their normal reqwest semantics, including the long-lived SSE response body.
For custom trust roots and client certificates, prefer reqwest's TLS options.
If using tls_backend_preconfigured, configure that backend's ALPN for
HTTP/1.1 yourself: reqwest does not rewrite a preconfigured TLS backend's ALPN,
even when HTTP/1.1 is selected on its builder. For rustls, set
ClientConfig::alpn_protocols to vec![b"http/1.1".to_vec()]. Incompatible
negotiation is rejected before any ACP messages are sent; HTTP/2 and HTTP/3
WebSocket negotiation is not implemented.
To share an already-built reqwest client and its connection pool, use the
HTTP/SSE-only constructor with an exact endpoint, including /acp or the
server's custom path:
let http = reqwest::Client::builder().build()?;
let transport = HttpClient::from_http_client(
"https://agent.example/acp",
http.clone(),
)?;from_http_client rejects ws:// and wss:// URLs before any network request.
An already-built reqwest client cannot be reconfigured to enforce the
WebSocket connection policies. Use the SDK builder for those URLs.
HttpClient itself can also be cloned to reuse its endpoint and underlying
HTTP client. Each connection has independent ACP transport state.
HttpClient::with_client(base_url, client) and
with_endpoint_and_client(endpoint, client) have been removed. An already-built
reqwest client cannot be reconfigured to enforce the WebSocket connection
policies, so migrate as follows:
- For HTTP/SSE or WebSocket configuration, move the reqwest builder settings into
HttpClient::builder(base_url).configure_http(|http| ...).build(). Do not call reqwest'sbuild()inside the callback. - For an exact endpoint, use
builder_with_endpoint(endpoint)instead. - To retain a shared, already-built HTTP/SSE client, use
from_http_client(exact_endpoint, client). Unlike the old base-URL constructor, this does not append/acp. - Code using
neworwith_endpointneeds no source changes, but WebSocket proxy discovery and TLS trust defaults change as described below.
Unconfigured WebSockets intentionally share reqwest's defaults with HTTP/SSE.
Previously, the tungstenite connection was direct and WSS used bundled WebPKI
roots. Now new, with_endpoint, and unconfigured builders discover environment
proxies, honor system proxies when reqwest's system-proxy feature is enabled,
and use reqwest's TLS verifier. With this SDK's rustls feature selection, that
is platform certificate verification instead of the old bundled roots. Downstream
reqwest feature selection and explicit TLS configuration can affect this behavior.
Consequently, an unchanged WebSocket URL may take a different network route or
trust a different set of certificate authorities.
To disable proxy discovery and explicitly supplied proxies, opt into a direct connection:
let transport = HttpClient::builder("wss://agent.example")
.configure_http(|http| http.no_proxy())
.build()?;For deterministic trust, supply the application's root certificates with
reqwest 0.13's tls_certs_only, which disables platform/built-in roots:
use agent_client_protocol_http::HttpClient;
fn direct_with_root(root_pem: &[u8]) -> Result<HttpClient, Box<dyn std::error::Error>> {
let root = reqwest::Certificate::from_pem(root_pem)?;
Ok(HttpClient::builder("wss://agent.example")
.configure_http(|http| http.no_proxy().tls_certs_only([root]))
.build()?)
}Supply the complete root set you intend to trust; this example does not recreate
the old bundled WebPKI root set. Use tls_certs_merge([root]) instead to add
private roots while retaining the selected verifier's default trust. Do not disable
certificate validation to work around a trust-store difference. The
proxy-header constraint applies when explicitly configuring proxies.