Skip to main content
The spec’s own error reference page says “Documentation coming soon”; the codes exist only in the schema’s ErrorCode definition. This page is where this SDK writes them down.

The rule

A handler throws AcpProtocolException (com.agentclientprotocol.sdk.error) to answer a request with an error; the SDK converts it to a JSON-RPC error response before sending it. Everything else is what a caller sees:
Some Javadoc on AcpProtocolException still shows it being caught after a client call, from before 0.80.0, when AcpError was split out as its own type and the SDK’s own local rejections were unified onto it too. A handler throwing AcpProtocolException is now the whole of that type’s job: a caller never catches it, only the AcpError the SDK builds from it on the wire.

One base class for every one of these: AcpException

AcpError, AcpProtocolException, AcpCapabilityException, AcpConnectionException, and AcpTimeoutException all extend com.agentclientprotocol.sdk.error.AcpException, so catch (AcpException e) handles every failure of a call in one place, whichever of these it turns out to be. AcpError extending AcpException is new: it used to extend RuntimeException directly, which meant catch (AcpException e), the one place the SDK itself documented for handling a call’s failures, silently missed every error the peer actually answered with.
A multi-catch catch (AcpException | AcpError e) no longer compiles: AcpError is now a subtype of AcpException, so listing both is redundant, not just unusual. Catch AcpException alone. If separate catch blocks are needed for AcpError specifically and AcpException generally, put the AcpError clause before the AcpException one, the usual Java rule for a subtype and its supertype; the other order compiles but the AcpException clause now catches AcpError too, silently taking over the more specific block.
CancellationException (java.util.concurrent) is the one type in the table above that is not an AcpException: it’s a JDK type reused for interrupt propagation, not an SDK-specific failure.

What a handler’s own exception becomes

Four different outcomes, depending on what the handler throws or lets escape:
  1. AcpProtocolException: sent as the handler intended, code, message and data unchanged. The caller’s AcpError carries exactly what was thrown.
  2. An AcpError the handler received from its own call to the peer, left to escape: passed on unchanged, not rewrapped. A handler that calls out, finds nothing of its own to add, and lets the failure propagate does not need to convert it: the caller sees the original code, message and data, not a flattened -32603.
  3. A CancellationException, an interrupt, or an AcpProtocolException with code -32800: all read as the handler cancelling its own work, ACP v1’s internal cancellation, and are answered -32800 with the exception’s own message (or "Request cancelled" if it has none), not flattened to -32603 the way an ordinary exception is. A handler that catches an interrupt from the SDK cancelling it and simply lets it propagate already gets the right answer without doing anything else.
  4. Anything else (a bare RuntimeException, a bug, an Error): answered -32603 with the generic message "Internal error" only. The exception’s own message is not sent to the peer (a security fix: a database error, a file path, or a URL with credentials could otherwise reach whatever sent the request). The real exception, with its stack trace, is logged at WARN on the side that handled the request, so it’s still visible to whoever runs that side.
Migration: a handler whose exception message the peer genuinely needs to see throws AcpProtocolException with that message explicitly; nothing else but a cancellation carries a message to the peer anymore.

Telling the peer’s error from the SDK’s own rejection

AcpError covers two different causes, and getData() is how to tell them apart. A peer’s actual error response carries the peer’s own code, message and data, unchanged. A response the SDK rejected locally always has code -32603 (JSON-RPC 2.0 defines no code for an invalid response) and a data map with a "reason" and the request’s "method":
Before 0.80.0, only inbound params were checked against the schema’s required fields; a peer’s {} silently read as a PromptResponse with a null stopReason. A result is now checked the same way, on both sides and down into nested records. AcpError.rejectedResponse(method, reason, message, detail) builds the error for the three response-shape reasons above; it’s public because the SDK’s own two sides both call it, not for application code to throw (a handler throwing an error uses AcpProtocolException instead, which the SDK never rejects as malformed, since it reads from the handler’s own return value, not off the wire). AcpError.getCode() returns the numeric code. getMessage() is the peer’s message text, plus any detail from the error’s data, with the code left out on purpose, so e.getCode() + " " + e.getMessage() names an error exactly once; toString() (what a stack trace prints) includes the code.

The v1 error codes

Every code in 0.80.0 matches the ACP v1 schema exactly; nothing is this SDK’s own invention. All seven live on AcpErrorCodes as int constants; throw AcpProtocolException with one of them from a handler, and compare AcpError.getCode() against them as a caller.

Converting between the two

Prefer JSONRPCError.from(...) over hand-building a wire error; it’s what the SDK’s own dispatch code uses to turn a thrown AcpProtocolException into the response it sends. toException() is a public utility with no internal caller of its own, useful if you’re building something (a proxy, a bridge) that needs to re-throw a peer’s error using this SDK’s exception type rather than AcpError.

Two more exceptions, specific to the sync API

A blocking call (AcpSyncClient, AcpSyncAgent, SyncPromptContext) can also fail with one of two exceptions that have nothing to do with a JSON-RPC error at all, peer-sent or locally detected:
com.agentclientprotocol.sdk.error.AcpTimeoutException (an AcpException) replaces what used to escape as Reactor’s internal Exceptions$ReactiveException. java.util.concurrent.CancellationException is thrown, with the thread’s interrupt flag left set, when a blocked call’s thread is interrupted, the same mechanism the SDK itself uses to cancel a sync handler. The async API doesn’t throw either of these: its Mono fails with the plain TimeoutException directly.
Timeouts and interrupts keep their own types rather than folding into AcpError: neither is a JSON-RPC error at all, peer-sent or locally detected, so there’s nothing for AcpError’s code and data to describe.

A lost connection: AcpConnectionException

A request can fail for a reason that has nothing to do with a JSON-RPC answer, peer-sent or locally detected, and nothing to do with a timeout either: the connection itself is gone, on either side.
com.agentclientprotocol.sdk.error.AcpConnectionException covers every case where a message can’t be carried because the connection is gone, on the client and the agent side alike: a request still waiting for its answer when the transport ends ("ACP session with agent terminated"), and a request or notification sent after that ("ACP client transport is not connected: ...") or after close() ("The transport is closed"). Before 0.80.0 these were a plain RuntimeException and an IllegalStateException respectively, so catch (AcpConnectionException e) missed the commonest connection failure, a peer that simply went away. Its cause, when there is one, is why the connection ended, such as the transport’s own failure, or a stdio agent’s exit code ("ACP agent process exited with code 137 (signal 9)" from awaitTermination()). A closed transport does not reopen: connect a new transport and client or agent to carry on.
Building a client or agent on a transport that refuses to connect or start at once (one already in use) still throws IllegalStateException, not AcpConnectionException: that’s a misuse of the transport, not a connection that was once alive and is now lost.

Two exceptions that mean the request was never sent

AcpCapabilityException and IllegalStateException both happen before a request leaves: capability negotiation, or connection lifecycle, ruled the call out, so there was nothing to send and nothing to time out.
A client call other than initialize fails locally with AcpCapabilityException, naming the capability, when the agent’s initialize answer didn’t advertise it: loadSession, listSessions, closeSession, deleteSession, resumeSession, forkSession, logout, and the providers/* calls. It also covers a session/new, session/load, session/resume, or session/fork that names non-empty additionalDirectories when the agent doesn’t advertise sessionCapabilities.additionalDirectories: check getAgentCapabilities().supportsAdditionalDirectories() before naming any. An agent call works the same way in the other direction for a client capability the client didn’t advertise: readTextFile, writeTextFile, any of the five terminal methods (create, output, waitForExit, kill, and release, all checked as of 0.80.0, not only create), or createElicitation for a mode the client didn’t announce, covered on Agent-to-Client Calls and Elicitation.
AcpCapabilityException.toProtocolException() is removed: it answered -32600, a code the SDK made up for the case, while the one refusal ACP actually specifies (a client asked for an elicitation mode it never declared) answers -32602 directly, which the SDK’s client already does without this method. A handler that needs to answer its own capability check as an error throws AcpProtocolException itself: new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, e.getMessage(), e.getCapability()).
Every AcpAsyncClient/AcpSyncClient call but initialize itself fails with IllegalStateException (“Call initialize() first”) until initialize has answered; before 0.80.0 it was sent anyway, relying on the agent to reject it. The check runs when the call is subscribed, not when it’s built, so client.initialize().then(client.newSession(request)) is fine without waiting for the first call to complete first. Extension calls (sendExtRequest, sendExtNotification) are outside ACP’s lifecycle and skip both checks.

Forward Compatibility

Open value types and Unknown* variants: the other half of staying safe against a newer peer

Migration Guide

Every error-code renumbering from 0.18.0, in one table