Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

HTTP / WebSocket Transport

agent-client-protocol-http exposes ACP agents over one /acp endpoint.

  • POST /acp with initialize creates a connection and returns Acp-Connection-Id.
  • Later POST /acp requests include Acp-Connection-Id; session-scoped requests also include Acp-Session-Id or params.sessionId.
  • GET /acp with Accept: text/event-stream streams agent messages over SSE. Use a connection-level stream for connection-scoped messages and per-session streams for session-scoped messages.
  • GET /acp with a WebSocket upgrade uses text frames for JSON-RPC messages.
  • DELETE /acp tears down the connection.

POST /acp request bodies are limited to 16 MiB.

Diagnostic Privacy

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

HttpClient starts every connection with an individual initialize and requires an individual initialize response. For compatibility with other clients, the server also accepts an initial batch when its first call-shaped entry is an initialize request. Valid and malformed response-only entries may precede it and are ignored; an invalid or call-shaped predecessor rejects the batch as an initial frame. The server forwards the complete frame and returns the complete grouped response in the POST response body; a successful initialize also adds Acp-Connection-Id. Lifecycle-sensitive calls should normally remain individual. If the agent emits a notification or callback before the initialize response is ready, including from a batched sibling, the server buffers that frame for the connection’s SSE stream until initialization completes.

After initialization, both transport shapes preserve batches:

  • On an established HTTP connection, one complete batch occupies one POST body. The server returns 202 Accepted; any grouped JSON-RPC reply is delivered through SSE as one array.
  • WebSocket sends one complete batch in one text frame and writes its grouped reply in one text frame.
  • A grouped HTTP reply is sent to a session stream only when all correlated entries have the same session route. If routes differ, it is sent on the connection-level stream so the array remains intact.

Entry validation, notification-only behavior, empty arrays, and malformed response filtering follow the shared transport batch contract.

HTTP + SSE Streams

After initialize, clients should open a connection-level SSE stream:

  • GET /acp
  • Accept: text/event-stream
  • Acp-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 /acp
  • Accept: text/event-stream
  • Acp-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.

Features

The crate does not enable either transport side by default. Opt into only the side(s) you need.

agent-client-protocol-http = { version = "3", features = ["client"] }
# For a server instead:
# agent-client-protocol-http = { version = "3", features = ["server"] }
# To enable both sides:
# agent-client-protocol-http = { version = "3", features = ["client", "server"] }

The client feature exposes HttpClient. The server feature exposes AcpHttpServer, ServerOptions, and CorsOptions.

Request Cancellation

Request cancellation is available through the core SDK:

agent-client-protocol-http = { version = "3", 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.

Server

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.

Browser authentication and credential policy

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.

Client

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.

Client Configuration

Use the client builder to configure headers, proxies, DNS, TLS, and timeouts. The configuration is used for both HTTP/SSE requests and WebSocket handshakes:

#![allow(unused)]
fn main() {
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.

Proxy Headers

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.

Reusing an HTTP Client

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:

#![allow(unused)]
fn main() {
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.

Migrating Custom Client Construction

HttpClient::with_client(base_url, client) and with_endpoint_and_client(endpoint, client) remain available but are deprecated. Existing HTTP/SSE calls keep their path handling, supplied client, and connection pool. They emit a deprecation warning so applications can migrate incrementally.

For ws:// and wss://, both deprecated constructors now return HttpClientError::WebSocketRequiresBuilder before any network I/O. Those callers must migrate to the builder: retaining the old signatures cannot make an opaque, already-built reqwest client enforce the WebSocket connection policies.

  • For HTTP/SSE or WebSocket configuration, move the reqwest builder settings into HttpClient::builder(base_url).configure_http(|http| ...).build(). Do not call reqwest’s build() 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 new or with_endpoint needs no source changes, but WebSocket proxy discovery and TLS trust defaults change as described below.

WebSocket Proxy and TLS Defaults

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:

#![allow(unused)]
fn main() {
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:

#![allow(unused)]
fn main() {
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.