Building an agent: annotations, the recommended way in
For nearly every agent, write a plain class, annotate it, and letAcpAgentSupport wire it up. No
@Initialize method is needed: the response is derived from the class, covered below.
@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:
@LoadSessionsetsloadSession;@ListSessions/@CloseSession/@ResumeSession/@DeleteSession/@ForkSessioneach set theirsessionCapabilitiesflag;@Logoutsetsauth.logout.@NewSession,@Prompt, and@Canceladvertise nothing; ACP requires every agent to support them.@SetSessionModeand@SetSessionConfigOptionadvertise nothing at theinitializelevel either: what they offer is per-session, in the modes or config options asession/new/load/resumeresponse carries. @Prompt(image, audio, embeddedContext)declares the prompt content the agent accepts, advertised aspromptCapabilities.@AcpAgent(mcpHttp, mcpSse)declares which MCP transports the agent connects to.@AcpAgent(additionalDirectories = true)advertisessessionCapabilities.additionalDirectories, needed before a client will send any; it requires a@NewSessionmethod, since the derived default answer would silently drop the directories a client sent, which ACP forbids.@AcpAgent(name, version, title)becomesagentInfo: an emptynamesends the classâs simple name; an emptyversionsends the jar manifestâsImplementation-Version, or"unknown"with neither; an emptytitlesends none.@AcpAgent(authMethods = @AuthMethod(...))becomesauthMethods. A terminal method is advertised only to a client that announcedclientCapabilities.auth.terminal; an agent-type method requires an@Authenticatehandler 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 aMono, 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:
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), soAcpClient.sync(transport)/AcpClient.async(transport)
is the only entry point:
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 ReactorMono) 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.
Related
Concepts
The vocabulary this page assumes: sessions, the prompt turn, capabilities
Transports
buildFactory() and AcpAgentFactory.sync(âŚ) for serving many connections