The acp-agent-support module provides an annotation-based programming model for building ACP agents. It eliminates boilerplate code while maintaining full compatibility with the builder-based API from acp-core.
@AcpAgent(name = "my-agent", version = "1.0.0")
class MyAgent {
@NewSession
NewSessionResponse newSession(NewSessionRequest req) {
return new NewSessionResponse("session-" + UUID.randomUUID(), null, null);
}
@Prompt
PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) {
ctx.sendMessage("Processing your request...");
ctx.sendMessage("Done!");
return PromptResponse.endTurn();
}
}
// Bootstrap and run
AcpAgentSupport.create(new MyAgent())
.transport(new StdioAcpAgentTransport())
.run();No @Initialize method is needed: the agent answers initialize with what its annotations declare
(see Advertising Capabilities).
Add the dependency to your pom.xml:
<dependency>
<groupId>com.agentclientprotocol</groupId>
<artifactId>acp-agent-support</artifactId>
<version>${acp.version}</version>
</dependency>This module transitively includes acp-annotations and acp-core.
| Annotation | Description |
|---|---|
@AcpAgent |
Marks a class as an ACP agent. Required on the agent class or a superclass of it: handlers are discovered on the class and its superclasses, so a framework proxy of the bean (Spring CGLIB, Quarkus ArC, Micronaut AOP) is accepted and invoked through the proxy. Its attributes are advertised in the initialize response: name, version and title as agentInfo (the name defaults to the class's simple name, the version to the jar manifest's Implementation-Version, else "unknown"), authMethods (@AuthMethods) as authMethods, and mcpHttp/mcpSse as mcpCapabilities. |
@AuthMethod |
One authentication method inside @AcpAgent(authMethods = ...): id, name, description, and type AGENT (served by @Authenticate) or TERMINAL (the client reruns the agent program with args and env). |
| Annotation | JSON-RPC Method | Advertises | Description |
|---|---|---|---|
@Initialize |
initialize |
(the exchange itself) | Optional. Without one, the agent answers with the response derived from its annotations; with one, the method's response is laid over the derived one (see Advertising Capabilities). |
@Authenticate |
authenticate |
authMethods, from @AcpAgent(authMethods = ...) |
Authenticates with one of the agent methods the agent advertised. |
@Logout |
logout |
auth.logout |
Clears stored credentials. |
@NewSession |
session/new |
nothing (baseline) | Creates a new agent session. Without one, the agent answers with a random session ID. |
@LoadSession |
session/load |
loadSession |
Loads an existing session by ID, replaying its history. |
@ResumeSession |
session/resume |
sessionCapabilities.resume |
Reconnects to an existing session without replaying history. |
@ListSessions |
session/list |
sessionCapabilities.list |
Lists sessions, optionally filtered by working directory. |
@CloseSession |
session/close |
sessionCapabilities.close |
Closes an active session. |
@DeleteSession |
session/delete |
sessionCapabilities.delete |
Permanently deletes a stored session. |
@ForkSession |
session/fork |
sessionCapabilities.fork |
Creates a session branched from an existing one (unstable, @UnstableAcpApi). |
@Prompt |
session/prompt |
promptCapabilities, from its image, audio and embeddedContext attributes |
Handles user prompts within a session. |
@SetSessionMode |
session/set_mode |
nothing (modes are offered per session) | Changes the operational mode of a session. Needs a @NewSession method that returns the modes. |
@SetSessionConfigOption |
session/set_config_option |
nothing (options are offered per session) | Changes a session configuration option. Needs a @NewSession method that returns the options. |
@ListProviders |
providers/list |
providers |
Lists the providers the agent can route to (unstable). |
@SetProvider |
providers/set |
providers |
Configures a provider (unstable). |
@DisableProvider |
providers/disable |
providers |
Disables a provider (unstable). |
@Cancel |
session/cancel |
nothing (baseline) | Handles cancellation notifications (fire-and-forget). The cancel does not end the prompt turn: the cancelled @Prompt method still returns, with stop reason cancelled, and the session rejects a new prompt until it has, or until the cancel grace period (default 60 s, cancelGracePeriod) passes and the agent answers cancelled itself. |
@ExtRequest("_name") |
any _-prefixed request |
nothing (agreed outside the protocol) | Serves a custom extension request; the name must start with _. |
@ExtNotification("_name") |
any _-prefixed notification |
nothing | Handles a custom extension notification; the name must start with _. |
A method without a handler for it is answered with -32601 (Method not found); a notification
without one is ignored.
| Annotation | Description |
|---|---|
@SessionId |
Injects the current session ID as a String, in handlers of session-scoped methods. |
@ConfigId |
In a @SetSessionConfigOption method, injects the id of the option being set as a String. |
@ConfigValue |
In a @SetSessionConfigOption method, injects the value being set, typed by the parameter: String (a select option), boolean/Boolean (a boolean option) or Object (either). A value of the other kind is answered -32602 without calling the method. |
A client learns what an agent supports from the agent's initialize response, and only uses what
is advertised there: a client that is not told loadSession never calls session/load. An annotated
agent's response is derived from its class, so every handler it has is advertised:
@AcpAgent(name = "notes-agent", version = "1.2.0", mcpHttp = true,
authMethods = {
@AuthMethod(id = "api-key", name = "API key", description = "Uses NOTES_API_KEY"),
@AuthMethod(id = "login", name = "Log in", type = AuthMethod.Type.TERMINAL, args = "--login") })
class NotesAgent {
@Authenticate AuthenticateResponse authenticate(AuthenticateRequest req) { ... }
@Logout LogoutResponse logout() { ... }
@LoadSession LoadSessionResponse load(LoadSessionRequest req) { ... }
@ListSessions ListSessionsResponse list(ListSessionsRequest req) { ... }
@Prompt(image = true, embeddedContext = true)
PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ... }
}answers initialize with
{
"protocolVersion": 1,
"agentCapabilities": {
"loadSession": true,
"sessionCapabilities": { "list": {} },
"mcpCapabilities": { "http": true, "sse": false },
"promptCapabilities": { "audio": false, "embeddedContext": true, "image": true },
"auth": { "logout": {} }
},
"authMethods": [
{ "id": "api-key", "name": "API key", "description": "Uses NOTES_API_KEY" },
{ "id": "login", "name": "Log in", "args": ["--login"], "type": "terminal" }
],
"agentInfo": { "name": "notes-agent", "version": "1.2.0" }
}- Capabilities come from the handler annotations, as the Advertises column of the handler table lists. A test fails the build if a handler annotation has no declared mapping.
- Prompt content and MCP transports the agent accepts are declared, default false:
@Prompt(image, audio, embeddedContext)and@AcpAgent(mcpHttp, mcpSse). - Auth methods are declared with
@AcpAgent(authMethods = @AuthMethod(...)). AnAGENTmethod needs an@Authenticatehandler (the builder fails otherwise); aTERMINALmethod is advertised only to a client that announcedclientCapabilities.auth.terminal, as ACP requires. agentInfois@AcpAgent'sname,versionandtitle.protocolVersionis the client's when the SDK speaks it, otherwise the latest the SDK speaks.
With an @Initialize method, the derived response is the base and the method's response is laid
over it: a capability is advertised when either side advertises it (so return InitializeResponse.ok()
keeps every derived capability, and a handler's capability cannot be withdrawn: remove the handler);
the returned authMethods follow the derived ones, replacing any with the same id; the returned
protocolVersion, and the returned agentInfo and _meta when not null, win. Use it to read the
client's request, or to advertise what annotations cannot express, such as
sessionCapabilities.additionalDirectories.
Handler methods support flexible signatures. The runtime automatically resolves parameters based on their types:
| Parameter Type | Source |
|---|---|
| The method's request type | The raw request or notification, in its handler: InitializeRequest, AuthenticateRequest, LogoutRequest, NewSessionRequest, LoadSessionRequest, ResumeSessionRequest, ListSessionsRequest, CloseSessionRequest, DeleteSessionRequest, ForkSessionRequest, PromptRequest, SetSessionModeRequest, SetSessionConfigOptionRequest, ListProvidersRequest, SetProviderRequest, DisableProviderRequest, CancelNotification. |
| Any type the JSON mapper can read | The params of an @ExtRequest or @ExtNotification (at most one such parameter). |
SyncPromptContext |
Synchronous context for sending messages, file I/O, permissions, etc. (in @Prompt handlers). |
PromptContext |
The async context the sync one wraps, for a @Prompt handler that composes Monos. |
@SessionId String |
The current session ID. |
@ConfigId String, @ConfigValue String/boolean/Object |
The option id and typed value, in a @SetSessionConfigOption handler. |
NegotiatedCapabilities |
The capabilities negotiated with the client on the request's connection (any handler). |
AcpSyncAgent / AcpAsyncAgent |
The agent serving the request's connection (any handler). |
Every handler, extension handlers included, can take NegotiatedCapabilities, AcpSyncAgent or
AcpAsyncAgent, resolved for the connection the request arrived on. The capabilities are what the
client offered in its initialize request, so they are available from the @Initialize handler
on. Under buildFactory(), one handler bean serves every connection, so a bean cannot keep "its"
agent in a field; take it as a parameter instead, for example to push changed config options
outside a prompt turn:
@ExtNotification("_example.com/models_changed")
void modelsChanged(ModelsChanged event, AcpSyncAgent agent) {
agent.sendSessionUpdate(event.sessionId(),
new ConfigOptionUpdate(configOptions(event.models())));
}@Initialize
InitializeResponse init() { ... }
@Initialize
InitializeResponse init(InitializeRequest req) { ... }
@Prompt
PromptResponse answer(PromptRequest req) { ... }
@Prompt
PromptResponse answer(PromptRequest req, SyncPromptContext ctx) { ... }
@Prompt
PromptResponse answer(SyncPromptContext ctx, @SessionId String sessionId) { ... }
@Prompt
String simpleAnswer(PromptRequest req) { ... } // Sent as an agent message chunk; the turn ends end_turn
@Prompt
void streamingAnswer(PromptRequest req, SyncPromptContext ctx) { ... } // Returns endTurn()
@Cancel
void onCancel(CancelNotification notification) { ... }The runtime automatically converts return values to protocol response types:
| Return Type | Conversion |
|---|---|
The method's response type (InitializeResponse, NewSessionResponse, PromptResponse, ...) |
Passed through directly. |
Mono, CompletionStage (CompletableFuture) or single-value Publisher of the response type (or of String for @Prompt) |
Awaited on the handler thread; the value is then handled as if returned directly. No value (an empty Mono or Publisher, or a stage that completes with null) gives no response, so the request is answered -32603, even for @Prompt, where a String method returning null ends the turn. |
String (@Prompt only) |
Sent to the client as an agent_message_chunk session update, then the turn ends (PromptResponse.endTurn()). |
void (@Prompt only) |
Converted to PromptResponse.endTurn(). |
void (@Cancel, @ExtNotification) |
Notifications have no response. |
Any value the JSON mapper can write (@ExtRequest) |
Sent as the extension request's result. |
A request handler other than @Prompt declared void, or declared to return a type that cannot
give its response, is rejected when the agent is built; one that returns null (or an empty Mono)
is answered with -32603 (Internal error): a request must get a result.
Annotation misuse fails when the agent is registered or built, with a message naming the class, the
method and the fix, never at the first request: two methods for one annotation, two handler
annotations on one method, a parameter no resolver supplies or that does not suit the method (the
request type of another method, @SessionId where there is no session, a prompt context outside
@Prompt, @ConfigValue outside @SetSessionConfigOption), and a return type that cannot give the
method's response. The one exception is the extension methods: on an @ExtRequest or @ExtNotification
method every parameter that is not a connection parameter is read from the params, so a prompt
context there is not rejected when the agent is built; instead every @ExtRequest call is answered
-32602 (invalid params). Parameters and return types that a custom ArgumentResolver or
ReturnValueHandler supports are left to it.
SyncPromptContext provides a rich API for agent-client interaction:
@Prompt
PromptResponse handle(PromptRequest req, SyncPromptContext ctx) {
// Get session info
String sessionId = ctx.getSessionId();
NegotiatedCapabilities caps = ctx.getClientCapabilities();
// Send messages and thoughts
ctx.sendMessage("Working on it...");
ctx.sendThought("Let me analyze this...");
// File operations (requires client capabilities)
String content = ctx.readFile("/path/to/file.txt");
ctx.writeFile("/path/to/output.txt", "content");
// Optional file read (returns Optional)
Optional<String> maybeContent = ctx.tryReadFile("/path/to/file.txt");
// Ask for user permission
boolean allowed = ctx.askPermission("Delete all files in /tmp?");
// Multiple choice (empty if the user cancelled)
Optional<String> choice = ctx.askChoice("Which format?", "JSON", "XML", "YAML");
// Execute terminal commands (requires client capabilities)
CommandResult result = ctx.execute("ls", "-la");
if (result.success()) {
ctx.sendMessage("Output: " + result.output());
}
return PromptResponse.endTurn();
}The raw ACP requests sit one step down, on ctx.client(): readTextFile, writeTextFile,
requestPermission, the five terminal calls and the elicitation calls, each taking the ACP request
record. Use them for what the convenience calls do not cover, for example a line range or a
permission request for a tool call you announced yourself:
String head = ctx.client()
.readTextFile(new ReadTextFileRequest(ctx.getSessionId(), "/path/to/file.txt", 1, 20))
.content();A prompt method learns that its prompt was cancelled from its context, whether by session/cancel
for its session or by $/cancel_request for its request; no @Cancel handler or shared state is
needed:
@Prompt
PromptResponse handle(PromptRequest req, SyncPromptContext ctx) {
for (Step step : plan(req)) {
if (ctx.isCancelled()) {
ctx.sendMessage("Stopped.");
return PromptResponse.cancelled();
}
step.run(ctx);
}
return PromptResponse.endTurn();
}
@Prompt
Mono<PromptResponse> handle(PromptRequest req, PromptContext ctx) {
return work(req, ctx)
.takeUntilOther(ctx.whenCancelled())
.defaultIfEmpty(PromptResponse.cancelled());
}SyncPromptContext.onCancel(Runnable) runs an action once on cancel, for work that cannot poll
(a subprocess, an HTTP call). After session/cancel the method must answer cancelled within the
cancel grace period (cancelGracePeriod, default 60 s); once it passes the agent answers
cancelled itself and interrupts the method's thread. After $/cancel_request the agent has already
answered (-32800, or cancelled if the session was cancelled too), so the method just stops.
Interceptors allow cross-cutting concerns like logging, metrics, or error handling:
public class LoggingInterceptor implements AcpInterceptor {
@Override
public boolean preInvoke(AcpInvocationContext context) {
log.info("Invoking: {}", context.getAcpMethod());
return true; // Continue processing
}
@Override
public Object postInvoke(AcpInvocationContext context, Object result) {
log.info("Result: {}", result);
return result;
}
@Override
public Object onError(AcpInvocationContext context, Throwable error) {
log.error("Error in {}: {}", context.getAcpMethod(), error.getMessage());
// Return null to keep the failure, return a replacement response, or throw an
// AcpProtocolException to answer with that JSON-RPC error instead
return null;
}
@Override
public void afterCompletion(AcpInvocationContext context, Throwable ex) {
// Called once for each call whose preInvoke returned true, however it ended;
// ex is what the call failed with, or null
}
@Override
public int getOrder() {
return 0; // Lower values execute first
}
}
// Register interceptor
AcpAgentSupport.create(new MyAgent())
.transport(transport)
.interceptor(new LoggingInterceptor())
.build();Extend argument resolution for custom parameter types:
public class UserResolver implements ArgumentResolver {
@Override
public boolean supportsParameter(AcpMethodParameter parameter) {
return parameter.getParameterType() == User.class;
}
@Override
public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) {
String sessionId = context.getSessionId().orElseThrow();
return userService.findBySession(sessionId);
}
}
// Register resolver
AcpAgentSupport.create(new MyAgent())
.transport(transport)
.argumentResolver(new UserResolver())
.build();The built-in handlers already accept a handler method's response itself, a Mono, a
CompletionStage (such as a CompletableFuture) and a single-value Publisher, and a String
from a @Prompt method. Register a handler for another return type. Custom handlers are asked
before the built-in ones, so a handler that also claims a built-in type replaces it: keep
supportsReturnType to the type you add. For example, a plain Future, as an ExecutorService
returns:
public class FutureHandler implements ReturnValueHandler {
@Override
public boolean supportsReturnType(AcpMethodParameter returnType) {
// Future itself only: a CompletableFuture stays with the built-in handler
return returnType.getParameterType() == Future.class;
}
@Override
public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType,
AcpInvocationContext context) {
try {
return ((Future<?>) returnValue).get(); // the method's response, such as a PromptResponse
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CancellationException("Interrupted while waiting for the handler's result");
}
catch (ExecutionException e) {
throw new ReturnValueHandlingException("The handler's Future failed", e.getCause());
}
}
}
// Register handler
AcpAgentSupport.create(new MyAgent())
.transport(transport)
.returnValueHandler(new FutureHandler())
.build();AcpAgentSupport.create(new MyAgent())
.transport(new StdioAcpAgentTransport())
.run();StreamableHttpAcpAgentTransport (module acp-streamable-http-jetty) serves the Streamable HTTP
profile and the WebSocket upgrade on one path and creates one agent per connection, which
buildFactory() provides:
AcpAgentFactory agents = AcpAgentSupport.create(new MyAgent()).buildFactory();
var server = new StreamableHttpAcpAgentTransport(8080, AcpJsonMapper.createDefault(), agents);
server.start().block(); // http://localhost:8080/acp and ws://localhost:8080/acpEvery connection's agent invokes the same MyAgent instance, the way every request to a Spring
controller reaches one bean: its handler methods run concurrently across connections and sessions,
so they must be thread-safe, with per-session state keyed by session id. Port 0 listens on an
ephemeral port; server.getPort() returns it once started. The listener binds the loopback
interface only unless StreamableHttpAcpAgentTransportOptions.builder().host(...) says otherwise,
and refuses browser requests from foreign origins.
InMemoryTransportPair pair = InMemoryTransportPair.create();
AcpAgentSupport support = AcpAgentSupport.create(new MyAgent())
.transport(pair.agentTransport())
.build();
support.start();
// Create client using the paired transport
AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build();AcpAgentSupport.create(agentInstance) // Start with agent instance
.transport(transport) // Required: set transport
.requestTimeout(Duration.ofSeconds(60)) // Optional: request timeout (default: 30s)
.cancelGracePeriod(Duration.ofSeconds(10)) // Optional: time a cancelled prompt has to return (default: 60s)
.maxPromptDuration(Duration.ofMinutes(5)) // Optional: longest a prompt may run (default: none)
.interceptor(interceptor) // Optional: add interceptor
.argumentResolver(resolver) // Optional: add custom resolver
.returnValueHandler(handler) // Optional: add custom handler
.build(); // Build the support instanceA builder can be built more than once: build() and buildFactory() add the default resolvers
and return value handlers after the custom ones at build time without changing the builder, and
later builder changes do not affect what was already built.
// From class (must have no-arg constructor)
AcpAgentSupport.create(MyAgent.class)
// With factory supplier
AcpAgentSupport.builder().agent(MyAgent.class, () -> new MyAgent(dependency))Either way the agent is instantiated once, when it is registered, and that instance serves every request, so state kept in its fields is shared across handlers.
AcpAgentSupport support = AcpAgentSupport.create(new MyAgent())
.transport(transport)
.build();
// Option 1: Non-blocking start
support.start();
// ... do other work ...
support.close();
// Option 2: Blocking run (blocks until closed)
support.run();@AcpAgent(name = "code-assistant", version = "1.0.0")
class CodeAssistant {
private final Map<String, List<String>> sessionHistory = new ConcurrentHashMap<>();
@NewSession
NewSessionResponse newSession(NewSessionRequest req) {
String sessionId = UUID.randomUUID().toString();
sessionHistory.put(sessionId, new ArrayList<>());
return new NewSessionResponse(sessionId, null, null);
}
@LoadSession
LoadSessionResponse loadSession(LoadSessionRequest req) {
if (!sessionHistory.containsKey(req.sessionId())) {
throw new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND,
"Session not found: " + req.sessionId());
}
return new LoadSessionResponse(null, null);
}
@Prompt
PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) {
String sessionId = ctx.getSessionId();
sessionHistory.get(sessionId).add(extractText(req));
ctx.sendThought("Analyzing the code...");
// Check if we can read files (capabilities are null before initialize)
NegotiatedCapabilities caps = ctx.getClientCapabilities();
if (caps != null && caps.supportsReadTextFile()) {
ctx.sendMessage("I can access files if needed.");
}
ctx.sendMessage("Here's my analysis...");
return PromptResponse.endTurn();
}
@SetSessionMode
SetSessionModeResponse setMode(SetSessionModeRequest req) {
return new SetSessionModeResponse();
}
@Cancel
void onCancel(CancelNotification notification, @SessionId String sessionId) {
// Clean up any long-running operations
log.info("Cancelled session: {}", sessionId);
}
private String extractText(PromptRequest req) {
return req.prompt().stream()
.filter(c -> c instanceof TextContent)
.map(c -> ((TextContent) c).text())
.collect(Collectors.joining("\n"));
}
}
public class Main {
public static void main(String[] args) {
AcpAgentSupport.create(new CodeAssistant())
.transport(new StdioAcpAgentTransport())
.interceptor(new MetricsInterceptor())
.run();
}
}The annotation-based API provides the same functionality as the builder API with less boilerplate:
AcpAgent.sync(transport)
.initializeHandler(req -> InitializeResponse.ok())
.newSessionHandler(req -> new NewSessionResponse("session-1", null, null))
.promptHandler((req, ctx) -> {
ctx.sendMessage("Hello!");
return PromptResponse.endTurn();
})
.build()
.run();@AcpAgent(name = "my-agent", version = "1.0")
class MyAgent {
@NewSession NewSessionResponse newSession() { return new NewSessionResponse("session-1", null, null); }
@Prompt PromptResponse prompt(SyncPromptContext ctx) {
ctx.sendMessage("Hello!");
return PromptResponse.endTurn();
}
}
AcpAgentSupport.create(new MyAgent()).transport(transport).run();Both approaches produce the same runtime behavior and can coexist in the same application. The annotated agent needs no initialize handler: it advertises what its annotations declare, where a builder agent's initialize handler must build the response itself.
acp-agent-support
├── AcpAgentSupport # Bootstrap and builder
├── AcpHandlerMethod # Method + bean encapsulation
├── invocation/ # Invocation model shared by the extension points
│ ├── AcpMethodParameter # Parameter metadata
│ └── AcpInvocationContext # Request context during invocation
├── resolver/ # Argument resolvers
│ ├── ArgumentResolver # Interface
│ ├── ArgumentResolverComposite
│ ├── PromptRequestResolver
│ ├── PromptContextResolver
│ ├── SessionIdResolver
│ └── ...
├── handler/ # Return value handlers
│ ├── ReturnValueHandler # Interface
│ ├── ReturnValueHandlerComposite
│ ├── DirectResponseHandler
│ ├── StringToPromptResponseHandler
│ ├── VoidHandler
│ ├── AsyncValueHandler
│ └── ExtensionResultHandler
└── interceptor/ # Interceptor chain
├── AcpInterceptor # Interface
└── InterceptorChain # Execution chain
AcpAgentSupport uses resolver, handler and interceptor; those use only invocation, never
AcpAgentSupport, so there are no package cycles. The module uses acp-core's public API only.
acp-annotations- Zero-dependency annotation definitionsacp-core- Core SDK with transport, schema, and client/agent APIs- SLF4J - Logging facade
- Project Reactor - Reactive streams (from acp-core)