Skip to content

AcpHarnessClient API

Every call starts with prompt() and describes one turn. Nothing is sent until call() or stream().

The API is @NullMarked (JSpecify): a reference is non-null unless it is annotated @Nullable, and the build checks that, so where a method says @Nullable the null is real and where it does not you need no check. An IDE that reads JSpecify annotations shows the same.

Blocking and streaming

// Blocking: wait for the finished answer.
AcpHarnessClient.AcpHarnessResponse response = acpHarnessClient.prompt()
        .session("review-123")                          // a named, reusable conversation
        .user("Inspect the pending change")
        .options(o -> o.timeout(Duration.ofMinutes(10)))
        .call();

String text = response.content();                        // assistant text only
AcpSchema.StopReason why = response.completion().reason();

// Streaming: every event as it happens.
Flux<AcpHarnessEvent> events = acpHarnessClient.prompt()
        .session("review-123")
        .user("Now focus on security")
        .stream()
        .events();

// Or just the text, e.g. for a server-sent-events endpoint.
Flux<String> text = acpHarnessClient.prompt("Explain the build").stream().content();
Method Meaning
session(name) Runs in a named conversation; repeat calls with the same name keep context. Without it the turn runs in a throwaway session closed when the turn ends. Names match [A-Za-z0-9][A-Za-z0-9._-]{0,127}.
user(text) Appends user text; call more than once to build up a prompt. prompt(text) is shorthand.
options(…) Per-turn overrides of timeout, model, mode and provider, as an AcpHarnessOptions or a builder lambda.
principal(p) Runs on behalf of a user: their MCP credentials, their session (see per-user OAuth).
call() Blocks and returns the finished turn.
stream() A Flux ending in exactly one terminal event. Cancelling the subscription cancels the turn on the agent.

A turn that ends early, because its subscription was cancelled or it exceeded timeout, is over for the caller at once. The agent is sent session/cancel, and the session stays busy until the agent answers the cancelled prompt. Goose, Codex and OpenCode answer within a few milliseconds; an agent that never answers is given 5 seconds. What the agent sends in the meantime is dropped rather than delivered to the next turn, and any permission request still open is answered cancelled. A turn started on the same session straight away waits for this, as it would for any turn still running.

Note

Workspace, credentials and MCP servers are not per-request options. They are fixed when the agent process starts, so a per-request override would silently do nothing.

Events

AcpHarnessEvent is a sealed interface of records, so a switch over it can be exhaustive:

acpHarnessClient.prompt().session("review-123").user("Review the diff").stream().events()
        .doOnNext(event -> {
            switch (event) {
                case AcpHarnessEvent.Text t            -> ui.append(t.text());
                case AcpHarnessEvent.Thought t         -> log.debug("thinking: {}", t.text());
                case AcpHarnessEvent.ToolCallStarted s -> ui.toolStarted(s.id(), s.title(), s.kind());
                case AcpHarnessEvent.ToolCallUpdated u -> ui.toolProgress(u.id(), u.status());
                case AcpHarnessEvent.PlanUpdated p     -> ui.replacePlan(p.entries());
                case AcpHarnessEvent.ConfigChanged c   -> log.info("agent reconfigured: {}", c.options());
                case AcpHarnessEvent.ModeChanged m     -> log.info("mode is now {}", m.modeId());
                case AcpHarnessEvent.UsageUpdated u    -> meter.context(u.contextUsed(), u.contextSize());
                case AcpHarnessEvent.Completed c       -> ui.done(c.reason());
                case AcpHarnessEvent.Failed f          -> ui.error(f.cause());
            }
        })
        .blockLast();
Event Meaning
Text Assistant-visible text, as deltas. What content() collects.
Thought Reasoning the agent chose to surface. Not part of the answer.
ToolCallStarted A tool call began: id, human-readable title, kind.
ToolCallUpdated Progress or completion of a call, keyed by the same id.
PlanUpdated The agent's current plan. Replaces any earlier plan in the turn. Also sent for an agent that keeps its plan in a tool, such as goose's todo: that tool's calls arrive as PlanUpdated, not as tool calls.
ConfigChanged, ModeChanged The agent reconfigured itself mid-turn.
UsageUpdated Context window used of its size, cumulative for the session, with cost when the agent reports one.
Completed (terminal) The turn reached the agent's own stop condition.
Failed (terminal) It did not: error, timeout, lost connection or cancellation.

Every turn emits exactly one terminal event, whatever happens, so a UI can close its progress indicator in one place. event.terminal() tests for either.

Named sessions

acpHarnessClient.prompt().session("pr-482").user("What does this PR change?").call();
acpHarnessClient.prompt().session("pr-482").user("Any security concerns in it?").call();   // remembers the first turn

Idle named sessions are closed after spring.ai.acp.harness.pool.session-ttl (60 minutes by default). Scope names to a unit of work, such as a PR, a ticket or a chat, so context does not grow without bound. Only one turn runs in a session at a time: a second waits for the first, for up to its own timeout, holding the thread that started it. A server answering many users should not hold threads that long, so it passes .waitIfBusy(Duration.ZERO) and gets an AcpHarnessSessionBusyException at once instead.

openSession(name) opens a session without spending a turn. It is how to learn what the negotiated tier actually achieved before the first prompt:

import org.springaicommunity.acp.harness.runtime.AcpHarnessRuntime.PortableOption;

SessionConfiguration config = acpHarnessClient.openSession("review-123").configuration();
config.applied(PortableOption.MODEL);          // the model this session is really using, or null
config.unsupported();                          // what was asked for and could not be honoured
config.resolutions().values().forEach(r ->     // requested, applied, and by which mechanism
        log.info("{}: {} -> {} ({})", r.option(), r.requested(), r.applied(), r.mechanism()));

Stored conversations

acpHarnessClient.sessions() manages the conversations the agent keeps. Every operation is optional in ACP, and the runtimes implement different subsets, so ask first:

AcpHarnessSessions sessions = acpHarnessClient.sessions();
if (sessions.supports(AcpHarnessSessions.Operation.LIST)) {
    List<StoredSession> stored = sessions.list();
}
if (sessions.supports(AcpHarnessSessions.Operation.LOAD)) {
    sessions.load("review-123", storedId);      // reattach and replay history under this name
}
sessions.close("review-123");                   // always succeeds, told or not
list load resume delete close
goose 1.57.0 yes yes no yes yes
codex-acp 2.5.0 yes yes yes yes yes
opencode 2.2.4 yes yes yes yes yes

An operation the agent does not advertise throws UnsupportedAcpHarnessOperationException naming the ACP method, rather than failing on the wire.

Other methods

Method Returns
runtimeId() The selected runtime, e.g. "goose".
harnessInfo() What the agent called itself during initialize.
protocolVersion() The ACP version the connection settled on (always 1 today).
notices() What the agent reported outside the protocol, such as an MCP server it could not load (see When a server fails to load).
isAlive() Whether the connection is usable. Optimistic: false is never wrong, true may be.

Tip

Migrating from the goose-buildpack java-wrapper? An AcpHarnessExecutor bean offers the old GooseExecutor method signatures and newline-delimited JSON events, with AcpHarnessOptions in place of GooseOptions.