Skip to main content
This page is where to start once you know the Concepts vocabulary and want to write code. It covers two independent choices: sync or async, and, for an agent only, annotations or the builder API. For nearly every agent, write a plain class, annotate it, and let AcpAgentSupport wire it up. No @Initialize method is needed: the response is derived from the class, covered below.
One annotation per ACP method (@Initialize, @NewSession, @Prompt, @Cancel, @Authenticate, @Logout, @SetSessionConfigOption, @ExtRequest/@ExtNotification for your own methods, and more), discovered once when AcpAgentSupport builds, on the class and its superclasses, so a framework-generated proxy (Spring CGLIB, Quarkus ArC, Micronaut AOP) is found through the annotated class it extends and invoked on the proxy itself, so its interceptors still run. A handler method takes its request type (optional), @SessionId String, and, in any handler, the connection’s NegotiatedCapabilities and AcpSyncAgent/AcpAsyncAgent; a @Prompt method additionally takes SyncPromptContext (or the async PromptContext, reachable from a sync one via ctx.async()).

Capabilities, agentInfo, and auth methods are derived from the class

With no @Initialize method, the response the agent sends is derived entirely from what the class declares:
  • Each handler annotation advertises the capability its method needs: @LoadSession sets loadSession; @ListSessions/@CloseSession/@ResumeSession/@DeleteSession/@ForkSession each set their sessionCapabilities flag; @Logout sets auth.logout. @NewSession, @Prompt, and @Cancel advertise nothing; ACP requires every agent to support them. @SetSessionMode and @SetSessionConfigOption advertise nothing at the initialize level either: what they offer is per-session, in the modes or config options a session/new/load/resume response carries.
  • @Prompt(image, audio, embeddedContext) declares the prompt content the agent accepts, advertised as promptCapabilities.
  • @AcpAgent(mcpHttp, mcpSse) declares which MCP transports the agent connects to.
  • @AcpAgent(additionalDirectories = true) advertises sessionCapabilities.additionalDirectories, needed before a client will send any; it requires a @NewSession method, since the derived default answer would silently drop the directories a client sent, which ACP forbids.
  • @AcpAgent(name, version, title) becomes agentInfo: an empty name sends the class’s simple name; an empty version sends the jar manifest’s Implementation-Version, or "unknown" with neither; an empty title sends none.
  • @AcpAgent(authMethods = @AuthMethod(...)) becomes authMethods. A terminal method is advertised only to a client that announced clientCapabilities.auth.terminal; an agent-type method requires an @Authenticate handler to serve it, checked when the agent is built.

Adding @Initialize: it overlays the derived response, it doesn’t replace it

An @Initialize method’s return value is layered over the derived one, not instead of it: a capability is advertised if either side advertises it (so a handler’s implied capability can never be silently withdrawn by returning a response that omits it); returned auth methods are appended to the derived ones, replacing any with the same id; a returned agentInfo, protocol version, or _meta wins over the derived value when it’s non-null. In short, @Initialize is for adding to or overriding specific fields, not for restating the whole response.

Return values

A request handler returns its response type, or a Mono, CompletionStage, or single-value Publisher of it (the runtime waits for it on the handler’s thread); a null or empty result answers -32603 (internal error), since a request always needs one. Only @Prompt may be void (the same as PromptResponse.endTurn()); a @Prompt method may also return (or asynchronously produce) a String, sent to the client as an agent message chunk before the turn ends the same way (null or empty sends nothing): the simple form the example above uses. A notification handler (@Cancel) returns void.

Cancellation

SyncPromptContext.isCancelled() (poll it between steps) or onCancel(Runnable) (register a callback, for work you can’t poll, like a subprocess); the async PromptContext.whenCancelled() composes into a Reactor pipeline. A @Prompt method doesn’t need to catch its own failures or pick a stop reason after session/cancel: the agent session answers cancelled regardless of what the method returns or throws, once session/cancel has arrived for that prompt. See Cancellation for the full annotation-model lifecycle.

Typed config values

A @SetSessionConfigOption method may take @ConfigId String and @ConfigValue (typed String, boolean/Boolean, or Object for either kind); a value of the wrong kind for the parameter’s type answers -32602 without calling the method. See Session Config Options for the full shape.

When something’s wrong

Registration errors are caught when the agent is built, each naming the class, the method, and the fix: two handler methods for one annotation (or two annotations on one method), a parameter no resolver can supply, @SessionId/@ConfigId/@ConfigValue on a method that doesn’t have one, a request handler declared void (or returning the wrong type), and @SetSessionMode/ @SetSessionConfigOption present without a @NewSession method to offer what they set. Every agent needs a @Prompt method; building one without it fails the same way. There’s no session-state or exception-handler annotation: keep per-session state in a thread-safe map keyed by session id, dropped in @CloseSession/@DeleteSession, and handle errors in the method itself or an AcpInterceptor.onError. onError can answer the request itself: throw AcpProtocolException there and that becomes the response, with the original failure attached as suppressed; it reaches only the interceptors whose preInvoke actually ran. afterCompletion(context, Throwable ex) takes the failure as a second parameter now (null on success); an override written against the old one-argument signature still compiles without @Override, but is silently never called again, so add @Override to every afterCompletion to catch this at compile time instead of finding out at runtime. For serving many remote connections (Streamable HTTP, WebSocket) from one annotated agent, AcpAgentSupport.Builder#buildFactory() returns an AcpAgentFactory instead of running one agent directly: every connection gets its own runtime, all dispatching to this one bean, which is why the bean itself must be thread-safe. It refuses a builder that also has transport(...) set, since a listener supplies its own transport per connection. See Transports.

Building an agent: the builder API

The lower-level option, for cases the annotation model doesn’t fit: dynamic handler registration, building an agent without a dedicated class, or needing a fresh agent instance (not a shared bean) per connection.
AcpAgent.AsyncAgentBuilder and AcpAgent.SyncAgentBuilder are final classes: build an agent with AcpAgent.async(transport)/AcpAgent.sync(transport) and wrap the result if you need to extend it, rather than subclassing the builder, which was never actually possible (their constructors were already package-private). The same shape as the annotated version, one xxxHandler(...) setter per ACP method instead of one annotated method. Builder agents get a default initialize too, so initializeHandler(...) is optional: it derives the same capabilities from which handlers are registered (no promptCapabilities or mcpCapabilities, which need annotation attributes the plain builder has no equivalent for), and agentInfo(Implementation) sets agentInfo on it without writing a handler. The rule is simpler than the annotated agent’s @Initialize overlay above: without an initializeHandler, the derived response is what’s sent; register one by hand, and your response is sent exactly as returned, with no merging over what the other handlers would otherwise imply. As of 0.80.0, newSessionHandler(...) is optional too: without one, build() answers session/new with NewSessionResponse.withGeneratedId() (a random UUID, no modes or config options), the same default an annotated agent without @NewSession already had. build() still requires a prompt handler; that’s the one ACP method with no default. (AcpAgentSupport.Builder#run(), the annotated builder’s one-call build().run() convenience, has no equivalent on this plain builder: call .build() then .run() on the result, as above.) As of 0.80.0, every typed builder handler except the prompt handler has a two-argument overload that receives the agent build() is about to return, so a handler that needs to call back into the client (or read NegotiatedCapabilities) no longer needs an AtomicReference to get it:
A one-argument handler (as shown everywhere else on this page) still works; only a handler that actually needs the agent takes the two-argument form. Name that second parameter something other than the local variable the built agent is assigned to, self in the example above: a lambda parameter can’t shadow a local variable in scope, so .newSessionHandler((req, agent) -> ...) fails to compile when the enclosing code also has AcpSyncAgent agent = AcpAgent.sync(transport).... AcpAgentFactory.sync(transport -> AcpAgent.sync(transport)...build()) is the builder-API way to serve many connections, building a fresh agent instance (not a shared bean) from the lambda each time; see Transports for when that distinction matters. A builder setter now rejects a null handler (IllegalArgumentException) and a second registration for the same method (IllegalStateException, naming the setter: “A handler for session/prompt is already registered; promptHandler was called twice on this builder”), instead of silently replacing the first one. Register each handler once; decide which implementation to use before calling the setter, not after. AcpSyncAgent and AcpAgentSupport implement AutoCloseable, with the same close semantics as AcpSyncClient: close gracefully, waiting up to 10 seconds, then close at once if that failed or took too long. Use try-with-resources on either, the same pattern as the client below. AcpSyncAgent.await() is renamed awaitTermination(), matching AcpAsyncAgent.

Clients: builder-only

There’s no annotation model for clients (handlers you register are mostly one-shot: a file handler, a permission handler, a session-update handler), so AcpClient.sync(transport)/AcpClient.async(transport) is the only entry point:
As of 0.80.0, fs.readTextFile is advertised automatically from the registered readTextFileHandler; no clientCapabilities(..) call needed. Set it explicitly only to override what the handlers would derive, or for a capability no handler expresses (session modes, terminal auth, _meta). AcpSyncClient implements AutoCloseable; a try-with-resources block is the usual shape for a short-lived client (a tutorial module, a CLI run). A long-lived client (a Spring Boot application) closes it from the owning context’s lifecycle instead. client.async() returns the AcpAsyncClient it blocks on, for composing one asynchronous call without switching the whole client over to the async facade.

Sync vs async, on either side

Independent of annotations-vs-builder: pick sync for straightforward blocking code (most agents and most client code), async (Project Reactor Mono) when you’re already reactive, need non-blocking I/O, or are composing several in-flight requests (as in graceful cancellation). AcpSyncAgent/AcpSyncClient each wrap an AcpAsyncAgent/AcpAsyncClient internally; .async() on either sync facade reaches the underlying async one directly, if you need to mix styles in one call.

Concepts

The vocabulary this page assumes: sessions, the prompt turn, capabilities

Transports

buildFactory() and AcpAgentFactory.sync(…) for serving many connections