stdio
The default transport: the client spawns the agent as a child process and they exchange newline-delimited JSON over stdin/stdout. The agent writes its logs to stderr. Closing a stdio client lets the agent exit by itself.StdioAcpClientTransport.closeGracefully() closes the agentâs standard input first and waits up to
END_OF_INPUT_WAIT_MILLIS (2 seconds) for the agent to exit on its own; only an agent still running
after that gets SIGTERM, and is killed 5 seconds later if it still hasnât stopped. A Java SDK stdio
agent treats end of input as a half-close, not a cancellation: it answers every request already
received, flushes, and exits 0. Exit code 143 now means the agent ignored end of input; 137 means it
was killed. The stop is logged exactly once, even when close() follows closeGracefully() in a
try-with-resources block.
System.in has a sharp edge. Closing a StdioAcpAgentTransport built on System.in does not
release it: its reader thread stays blocked until the next line arrives or input ends, because a read
of System.in cannot be interrupted. Pass explicit streams, or use acp-testâs in-memory transport,
in embedders and tests.
The clientâs own process doesnât exit when the agent process does.
StdioAcpClientTransport.awaitProcessExit() (renamed from awaitForExit()) blocks until the agent
process itself exits, as distinct from awaitTermination(), which completes when the transport
ends (which can happen first, for instance on a graceful close). A thread blocked in
awaitProcessExit() thatâs interrupted throws CancellationException with the interrupt flag kept.
Sending through a transport after itâs closed fails with AcpConnectionException (âThe transport is
closedâ) rather than silently dropping the message and leaving the caller to wait out a timeout.
The agent process inherits the clientâs whole environment by default. Every environment variable
the client process has, including secrets, reaches the agent subprocess unless you say otherwise.
AgentParameters.Builder.inheritEnvironment(false) starts the process from an empty environment
instead, with only the âsafeâ defaults (HOME, LOGNAME, PATH, SHELL, TERM and USER; on
Windows, a different, larger set) and whatever addEnvVar(...) adds on the builder:
Streamable HTTP and WebSocket
ACP over HTTP needs almost nothing from a web framework
The host contract is about eight small types, and a host is a few hundred lines. Thatâs why each
frameworkâs integration is a thin layer rather than a reimplementation of the protocol.
Identity
Method routing
Agent-to-client calls (
session/update, session/request_permission, fs/*, terminal/*) travel
on the sessionâs own stream.
How a connection becomes an agent
Each new connection, whether aninitialize POST or a WebSocket upgrade, gets a
RemoteAcpConnection, which asks the AcpAgentFactory for a fresh agent bound to a
connection-scoped transport. Annotated agents get this through
AcpAgentSupport.Builder.buildFactory(); the handler bean it wraps is shared across every
connection, so it must be thread-safe.
Plain http:// is first-class
The client probes with a bodiless GET so an h2c (HTTP/2 cleartext) server can upgrade the connection;
otherwise it falls back to pinned HTTP/1.1. The built-in Jetty listener serves both HTTP/1.1 and h2c,
with maxConcurrentStreamsPerConnection defaulting to 1024.
The mountable servlet
StreamableHttpAcpServlet (module acp-http-servlet, no Jetty dependency) works in any Servlet 6
container, with async support enabled; init()/destroy() drive its lifecycle.
StreamableHttpAcpServlet(mapper, factory) and the mapper-less StreamableHttpAcpServlet(factory)
(defaulting to AcpJsonMapper.createDefault()) both still exist, alongside the newer
StreamableHttpAcpServlet(AcpHttpEndpoint), which wraps a host contract youâve built or customized
yourself. Every protocol rule (routing, sessions, the Origin check, keep-alive) lives behind that
AcpHttpEndpoint contract; the servletâs own service() method hands every request straight to it, so
a subclass overriding doGet, doPost or doDelete no longer intercepts anything.
The servlet upgrades WebSocket requests itself, through Jakarta WebSocket 2.1
(ServerContainer.upgradeHttpToWebSocket), on any container that has an implementation (Tomcat, Jetty,
Undertow); a container without one answers the upgrade 501.
A mountable WebFlux route
AcpWebFluxHost (module acp-http-webflux) is the same idea for a plain Spring WebFlux application,
with no Spring Boot autoconfiguration involved: new AcpWebFluxHost(endpoint).routerFunction("/acp")
returns a RouterFunction<ServerResponse> you add to your own router, Streamable HTTP and WebSocket on
that one path. It depends only on the host contract and spring-webflux (provided), so it works in any
WebFlux application, Spring Boot or not.
Shutdown
Closing the servlet (destroy(), closeGracefully()) or the standalone listener waits at most
StreamableHttpAcpAgentTransportOptions.shutdownTimeout (default 5 seconds) for connected agents to
finish, then closes everything else at once; an initialize still in flight when shutdown starts is
answered 503 immediately. Every open stream is drained first: an SSE stream gets a closing comment
(: shutting down) before it completes, and a WebSocket closes with code 1001 (going away), not
1000, so a client distinguishing a normal close from a server shutdown sees the right one. The drain
waits for each WebSocketâs close frame to actually go out, on every host, not just for the agent to
close: a client that never answers the close holds graceful shutdown no longer than the shutdown
timeout, after which itâs logged at WARN and closed at once.
SSE responses also carry Cache-Control: no-cache and X-Accel-Buffering: no, so nginx and similar
reverse proxies donât buffer them; text/event-stream is excluded from Spring Bootâs own response
compression automatically, since a compressed SSE stream would otherwise sit in the compressorâs
buffer instead of reaching the client as itâs written.
Under Spring Boot specifically, graceful shutdown waits for every open SSE stream
(spring.lifecycle.timeout-per-shutdown-phase, 30 seconds by default) before destroy() even runs.
Call servlet.closeGracefully() yourself from a SmartLifecycle.stop() in the default phase, rather
than relying on destroy() alone, or that 30-second wait looks like a hang.
Port 0 and other options
Pass port0 to listen on an ephemeral port; call getPort() after start() to get the port that
was actually bound. StreamableHttpAcpClientTransportOptions.maxSseStreams defaults to 64 (one per
open session, plus the connection stream); raise it if a single client holds many sessions open at
once.
An ACP WebSocket is a long-lived session, not an ordinary HTTP connection: it can legitimately sit
quiet while a person thinks or an agent works, so ACP gives it its own idle timeout rather than
reusing whatever a framework tunes for short-lived keep-alive connections, which would otherwise close
real work mid-session. Every WebSocket host (the servlet, the standalone listener, WebFlux, and
Quarkus) closes an idle connection after 30 minutes by default with code 1001, set with
StreamableHttpAcpAgentTransportOptions.builder().webSocketIdleTimeout(Duration); the frameworkâs own
idle setting may also apply.
A connection that never sends initialize is a different problem, not legitimate idling but an
abandoned handshake, so it closes much sooner: 30 seconds by default, with code 1008
(.initializeTimeout(Duration)). The same timeout bounds a POST initialize the agent hasnât
answered yet, which gets 500 instead.
Running on your own executor
StreamableHttpAcpClientTransportOptions.builder().executor(Executor),
new WebSocketAcpClientTransport(URI, AcpJsonMapper, Executor), and
StreamableHttpAcpAgentTransportOptions.builder().executor(Executor) run the transportâs own work (the
HttpClient, SSE reads, frame writes, or, for the listener, every request on Jettyâs
VirtualThreadPool) on an executor you supply, instead of one the transport creates and owns; nothing
shuts it down, since the application owns it. On JDK 21 and later, each of these defaults to virtual
threads unless you pass your own executor; virtualThreads(false) on the same builders keeps
platform-thread pools instead, for an application that hasnât opted into virtual threads.
Deployment topologies
Where ACP actually listens depends on how the agent is built and served. One table, one port column each:- Plain Java, with no framework at all: the SDKâs own server serves ACP over HTTP and WebSocket on one port.
- Quarkus serves ACP over HTTP and WebSocket on Quarkusâs own HTTP port, alongside the applicationâs other routes. One server.
- Micronaut keeps the applicationâs own traffic on Micronautâs server
(
micronaut.server.port), while ACP over HTTP and WebSocket runs on a second port (acp.agent.transport.http.listener.port). Two servers in one JVM: two ports, and separate TLS, security and metrics configuration for each. - A Spring Boot servlet web application (a container such as Tomcat) serves ACP over HTTP, SSE and WebSocket, all on the applicationâs own port, through the mounted servlet and the applicationâs own filter chain: one server, no second port.
- A Spring Boot reactive (WebFlux) web application serves ACP the same way, on the applicationâs
own port (Reactor Netty by default), as a
RouterFunctionrouted through the applicationâs ownWebFilters: one server, no second port. - A Spring Boot non-web application has no servlet or reactive web container, so the SDKâs own server serves ACP over HTTP and WebSocket on its own port, the same shape as plain Java.
127.0.0.1 by default, not every
interface: plain Java, Micronautâs second port, and a Spring Boot non-web application. See
Safe by default below for how to expose it deliberately.
Planned: the same one-port treatment for Micronaut, mounting ACP on Micronautâs own server
instead of a second port. Not available yet. Spring Bootâs servlet and reactive (WebFlux) web
applications both already serve WebSocket on their application port, alongside HTTP and SSE, as of
this release.
Logging and sensitive data
At INFO and above, the SDK logs no message content. A malformed message that fails validation is refused with an exception naming only the field at fault, never the message text; the WebSocket client logs its endpoint as scheme, host, port and path, never the query string (which can carry an access token) or user information (which can carry a password); and a peerâs error answer is logged at WARN by its code and the SDKâs own description of it (-32603 Internal error), with the peerâs
own message and data logged at DEBUG only, since that text is the peerâs, not the SDKâs.
Two things still bypass this by design:
- The agentâs standard error.
StdioAcpClientTransportlogs every line a stdio agent writes to standard error at INFO, unfiltered, oncom.agentclientprotocol.sdk.client.transport.agent-stderr: an agentâs own diagnostics can carry sensitive data (paths, prompts, account details). Silence it by setting that loggerâs level toWARNorOFF, or route the lines yourself withsetStdErrorHandler. - DEBUG and TRACE. Below INFO, loggers under
com.agentclientprotocol.sdklog message payloads in full: the stdio clientâs sent and received lines, session messages, and the session updates the framework integrations log by default when no update handler of your own is registered. Donât enable DEBUG or TRACE on this package where logs are shipped or retained.
Not yet supported in 0.80.0
- WebSocket mounted on Micronautâs own server. Still a second port; see Deployment topologies above for whatâs planned here.
- HTTP/2 configuration for the servlet. Thatâs the surrounding containerâs responsibility, not this SDKâs.
- TLS on the built-in listener. It opens a plain connector only; terminate TLS in front of it, or use the mountable servlet in a container that already handles TLS.
- A client-side listener. Clients in this SDK only connect; they donât accept inbound connections.
- An oversized request can surface as a connection error instead of 413, on some hosts. The
endpoint refuses a body over
maxPostBodyBytes(16 MiB) by itsContent-Length, before reading it. On Tomcat as a WebFlux server, and on Jetty, the connection can then close while the body is still arriving, and a client still sending can lose the 413 it already received to that reset. The request is refused either way; a valid request is unaffected. Tomcat as a servlet container (Spring MVC) and Reactor Netty arenât affected. A fix is planned for 0.81.0. - Outbound queues on the stdio transports and the WebSocket client are unbounded. A peer that
keeps sending requests without reading the responses can grow the senderâs memory, roughly one
queued response per request still unread (an
fs/read_text_fileresponse, for one, can be large). Server-side HTTP and WebSocket hosts are already bounded: a backpressured SSE subscriber is closed pastmaxPendingSseEvents, and a WebSocket connection pastmaxWebSocketPendingFrames(1024 each, by default). Bounded outbound queues on the stdio transports and the WebSocket client are planned for 0.81.0. - Jetty as a WebFlux server closes idle ACP WebSockets early. Jettyâs own connection idle timeout
(30 seconds by default) overrides ACPâs own
web-socket-idle-timeout(30 minutes by default) when Jetty is the WebFlux server. Setserver.jetty.connection-idle-timeoutat least as long as ACPâs own setting, or use Reactor Netty, the default and recommended server for WebFlux. A fix is planned for 0.80.1.
Related
Spring Boot: Remote Agents over HTTP
acp-autoconfigâs property-driven setup for the servlet and listener cases
Migration Guide
The acp-websocket-jetty removal this transport replaces