
Implements Agent Client Protocol for integrating AI agents, supporting client and agent capabilities, standardized AI interface, JSON-RPC handling, session management, and file operations. Includes examples and STDIO transport communication.
Modern Kotlin toolkit for building software that speaks the Agent Client Protocol (ACP). Ship ACP-compliant agents, clients, and transports for IDE plugins, CLIs, backend services, or any JVM host—all with one cohesive SDK.
ACP standardises how AI agents and clients exchange messages, negotiate capabilities, and move files. This SDK provides a Kotlin implementation of that spec:
| Module | Description | Main packages |
|---|---|---|
:acp-model |
Pure data model for ACP messages, capabilities, and enums | com.agentclientprotocol.model.* |
:acp |
Core agent/client runtime with STDIO transport |
agent, client, protocol, transport
|
:acp-ktor |
Shared infrastructure for Ktor-based transports | ktor |
:acp-ktor-client |
Ktor HTTP/WebSocket client helper | ktor.client |
:acp-ktor-server |
Ktor server-side transport utilities | ktor.server |
:acp-ktor-test |
Test fixtures and fake transports for ACP flows | ktor.test |
:samples:kotlin-acp-client-sample |
Complete runnable client + agent reference implementation | samples |
Artifacts are published under com.agentclientprotocol. The default build version is 0.3.0-SNAPSHOT; release builds use 0.3.0.
repositories {
mavenCentral()
}
dependencies {
implementation("com.agentclientprotocol:acp:0.3.0-SNAPSHOT")
// Optional extras:
// implementation("com.agentclientprotocol:acp-ktor-client:0.3.0-SNAPSHOT")
// implementation("com.agentclientprotocol:acp-ktor-server:0.3.0-SNAPSHOT")
}Snapshot builds: When consuming the
-SNAPSHOTartifacts outside Maven Central, add the repository that hosts your snapshot (e.g. GitHub Packages or an internal mirror).
Set up an AgentSupport, wire the standard STDIO transport, and stream responses. The example below also shows how to call the optional FileSystemOperations extension so the agent can read files through the client.
import com.agentclientprotocol.agent.Agent
import com.agentclientprotocol.agent.AgentInfo
import com.agentclientprotocol.agent.AgentSession
import com.agentclientprotocol.agent.AgentSupport
import com.agentclientprotocol.agent.clientInfo
import com.agentclientprotocol.client.ClientInfo
import com.agentclientprotocol.client.FileSystemOperations
import com.agentclientprotocol.common.Event
import com.agentclientprotocol.common.SessionParameters
import com.agentclientprotocol.common.remoteSessionOperations
import com.agentclientprotocol.model.AgentCapabilities
import com.agentclientprotocol.model.ContentBlock
import com.agentclientprotocol.model.LATEST_PROTOCOL_VERSION
import com.agentclientprotocol.model.PromptResponse
import com.agentclientprotocol.model.SessionId
import com.agentclientprotocol.model.SessionUpdate
import com.agentclientprotocol.model.StopReason
import com.agentclientprotocol.protocol.Protocol
import com.agentclientprotocol.transport.StdioTransport
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonElement
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
// 1. Build a dedicated AgentSession implementation for each connection.
private class TerminalAgentSession(
override val sessionId: SessionId
) : AgentSession {
override suspend fun prompt(
content: List<ContentBlock>,
_meta: JsonElement?
): Flow<Event> = flow {
// Echo back what the user typed.
val userText = content.filterIsInstance<ContentBlock.Text>().joinToString(" ") { it.text }
emit(
Event.SessionUpdateEvent(
SessionUpdate.AgentMessageChunk(ContentBlock.Text("Agent heard: $userText"))
)
)
// Optional extension call via the coroutine context.
val context = currentCoroutineContext()
val clientCapabilities = context.clientInfo.capabilities
if (clientCapabilities.fs?.readTextFile == true) {
val fs = context.remoteSessionOperations(FileSystemOperations)
val readmeSnippet = fs.fsReadTextFile("README.md").content.take(120)
emit(
Event.SessionUpdateEvent(
SessionUpdate.AgentMessageChunk(ContentBlock.Text("README preview: $readmeSnippet…"))
)
)
}
// Finish the turn once updates are sent.
emit(Event.PromptResponseEvent(PromptResponse(StopReason.END_TURN)))
}
override suspend fun cancel() {
// No long-running work in this demo, so nothing to clean up yet.
}
}
// 2. Implement AgentSupport: negotiate capabilities and build per-session handlers.
private class TerminalAgentSupport : AgentSupport {
override suspend fun initialize(clientInfo: ClientInfo) = AgentInfo(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = AgentCapabilities() // advertise baseline agent features
)
override suspend fun createSession(sessionParameters: SessionParameters): AgentSession {
// 3. Instantiate the session implementation defined above.
val sessionId = SessionId("session-${System.currentTimeMillis()}")
return TerminalAgentSession(sessionId)
}
override suspend fun loadSession(sessionId: SessionId, sessionParameters: SessionParameters): AgentSession =
// Rehydrate existing sessions with the provided identifier.
TerminalAgentSession(sessionId)
}
fun main(): Unit = runBlocking {
// 4. Bridge STDIO to the Protocol so the agent can speak ACP over stdin/stdout.
val transport = StdioTransport(
parentScope = this,
input = System.`in`.asSource().buffered(),
output = System.out.asSink().buffered()
)
val protocol = Protocol(this, transport)
// 5. Register the agent and declare which remote extensions it will use.
Agent(
protocol = protocol,
agentSupport = TerminalAgentSupport(),
remoteSideExtensions = listOf(FileSystemOperations)
)
// 6. Start listening for messages from the client.
protocol.start()
}Create a Client with your own ClientSessionOperations implementation. This sample exposes FileSystemOperations, grants tool-call permissions, and prints streamed updates from the agent.
import com.agentclientprotocol.client.*
import com.agentclientprotocol.common.ClientSessionOperations
import com.agentclientprotocol.common.Event
import com.agentclientprotocol.common.SessionParameters
import com.agentclientprotocol.model.*
import com.agentclientprotocol.protocol.Protocol
import com.agentclientprotocol.transport.StdioTransport
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonElement
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.readText
import kotlin.io.path.writeText
// 1. Describe how the client should create session handlers for each connection.
private class TerminalClientSupport(private val projectDir: Path) : ClientSupport {
override suspend fun createClientSession(
session: ClientSession,
_sessionResponseMeta: JsonElement?
): ClientSessionOperations = TerminalSession(projectDir)
}
private class TerminalSession(
private val projectDir: Path
) : ClientSessionOperations, FileSystemOperations {
override suspend fun requestPermissions(
toolCall: SessionUpdate.ToolCallUpdate,
permissions: List<PermissionOption>,
_meta: JsonElement?
): RequestPermissionResponse =
// Grant whichever option was first in the list (swap for real UX).
RequestPermissionResponse(RequestPermissionOutcome.Selected(permissions.first().optionId))
override suspend fun notify(notification: SessionUpdate, _meta: JsonElement?) {
// Surface streaming updates back to the host application.
println("Agent update: $notification")
}
override suspend fun fsReadTextFile(
path: String,
line: UInt?,
limit: UInt?,
_meta: JsonElement?
): ReadTextFileResponse =
// Resolve file paths relative to the workspace root the client chose.
ReadTextFileResponse(projectDir.resolve(path).readText())
override suspend fun fsWriteTextFile(
path: String,
content: String,
_meta: JsonElement?
): WriteTextFileResponse {
// Allow the agent to write files through the same extension API.
projectDir.resolve(path).writeText(content)
return WriteTextFileResponse()
}
}
fun main(): Unit = runBlocking {
val transport = StdioTransport(
parentScope = this,
input = System.`in`.asSource().buffered(),
output = System.out.asSink().buffered()
)
val protocol = Protocol(this, transport)
val projectRoot = Paths.get("").toAbsolutePath()
val client = Client(
// 2. Register the client support and advertise which extensions you implement.
protocol = protocol,
clientSupport = TerminalClientSupport(projectRoot),
handlerSideExtensions = listOf(FileSystemOperations)
)
protocol.start()
client.initialize(
// 3. Negotiate capabilities so the agent knows extensions are available.
ClientInfo(
capabilities = ClientCapabilities(
fs = FileSystemCapability(readTextFile = true, writeTextFile = true)
)
)
)
val session = client.newSession(
// 4. Launch a session pointing at the project workspace.
SessionParameters(
cwd = projectRoot.toString(),
mcpServers = emptyList()
)
)
session.prompt(listOf(ContentBlock.Text("Hello agent!"))).collect { event ->
when (event) {
// 5. React to streaming updates and final responses.
is Event.SessionUpdateEvent -> println("Agent update: ${event.update}")
is Event.PromptResponseEvent -> println("Prompt finished: ${event.response.stopReason}")
}
}
}Prefer a fully wired example? Launch the repository sample that pairs the agent and client shown above:
./gradlew :samples:kotlin-acp-client-sample:run
# Gemini interactive client (requires external Gemini ACP agent)
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.GeminiClientAppKt
# Direct v2 client and agent
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.V2SimpleAgentAppKt
# Select v1 or v2 with ClientNegotiator, then run the v2 conversation
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.V2NegotiationAppKt
# Query the unstable auth/status method over v1 and v2
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.AuthStatusAppKt| Project | Shows | Command |
|---|---|---|
samples:kotlin-acp-client-sample |
End-to-end agent + client with STDIO transport | ./gradlew :samples:kotlin-acp-client-sample:run |
GeminiClientApp.kt |
Interactive CLI client that talks to an external Gemini ACP agent | ./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...GeminiClientAppKt |
V2SimpleAgentApp.kt |
Direct v2 client and agent conversation | ./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...V2SimpleAgentAppKt |
V2NegotiationApp.kt |
Client negotiation followed by a v2 conversation | ./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...V2NegotiationAppKt |
AuthStatusApp.kt |
Unstable auth/status query over v1 and v2, without a session |
./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...AuthStatusAppKt |
Each sample includes comments that explain the protocol lifecycle and can be used as templates for real applications.
The v2 samples expose a response mode through session config options. The client selects “Uppercase once”
with session/set_config_option; after replying, the agent resets to Echo and sends a config_option_update.
ClientSession.setConfigOption returns the complete option list rather than the one option that changed,
because one choice can affect the others, and later changes arrive on session.updates as
SessionUpdate.ConfigOptionUpdate — also carrying the full list.
Agents report those changes with agent.v2.ClientOperations.notify(SessionUpdate.ConfigOptionUpdate(...)),
which works while the session is idle as well as mid-turn. ACP v2 uses config options for mode selection;
dedicated modes remain available in the SDK's v1 API.
An agent can advertise the draft auth/status query with auth.status = true in its initialize response.
V1 uses agentCapabilities.auth.status. V2 uses capabilities.auth.status, independent of authMethods.
This V2 location follows the V2 authentication model; the draft RFD does not yet define a V2 schema entry.
The capability, request and response types, methods, and runtime hooks require @OptIn(UnstableApi::class).
After initialization, Client.authStatus queries the agent without a session or prompt. Agents answer through
AgentSupport.authStatus with AuthStatusResponse in V1, or StatusAuthResponse in V2.
The response can include a human-readable message and _meta. authenticated means credentials are configured, not necessarily valid.
Calls with a missing or false capability fail locally. An agent without a status handler returns JSON-RPC method not found (-32601).
AuthStatusApp.kt shows the V1 and V2 flows end to end.
AcpRequest, AcpResponse, AcpWithMeta)acp-ktor* modules)┌─────────────────┐ ┌─────────────────┐
│ Agent App │ │ Client App │
│ (AgentSupport & │ │ (ClientSupport &│
│ AgentSession) │ │ ClientSessionOps│
├─────────────────┤ ├─────────────────┤
│ Agent runtime │ │ Client runtime │
│ (`Agent`) │ │ (`Client`) │
├─────────────────┤ ├─────────────────┤
│ Protocol │ │ Protocol │
├─────────────────┤ ├─────────────────┤
│ Transport │ │ Transport │
│ (STDIO, Ktor) │◄──►│ (STDIO, Ktor) │
└─────────────────┘ └─────────────────┘
Lifecycle overview: clients establish a transport, call initialize to negotiate capabilities, open sessions (session.new), send prompts (session.prompt), and react to streamed updates (tool calls, permissions, status). Agents implement the mirrors of these methods, delegating file and permission requests back to the client when required. The Agent and Client runtime classes sit between your business logic (AgentSupport/AgentSession or ClientSupport/ClientSessionOperations) and the lower-level Protocol/transport layers.
JSON-RPC batches are supported by the shared protocol and both built-in transports.
Known limitation — JSON syntax: the SDK accepts any JSON syntax that kotlinx.serialization's Json.parseToJsonElement accepts, including some non-standard JSON such as unquoted primitive tokens. For simplicity, we rely on the library's parser and do not reimplement JSON syntax validation. Parsed values still undergo JSON-RPC envelope validation.
Contributions are welcome! Please open an issue to discuss significant changes before submitting a PR.
./gradlew check to execute the test suite.Distributed under the MIT License. See LICENSE.txt for details.
Modern Kotlin toolkit for building software that speaks the Agent Client Protocol (ACP). Ship ACP-compliant agents, clients, and transports for IDE plugins, CLIs, backend services, or any JVM host—all with one cohesive SDK.
ACP standardises how AI agents and clients exchange messages, negotiate capabilities, and move files. This SDK provides a Kotlin implementation of that spec:
| Module | Description | Main packages |
|---|---|---|
:acp-model |
Pure data model for ACP messages, capabilities, and enums | com.agentclientprotocol.model.* |
:acp |
Core agent/client runtime with STDIO transport |
agent, client, protocol, transport
|
:acp-ktor |
Shared infrastructure for Ktor-based transports | ktor |
:acp-ktor-client |
Ktor HTTP/WebSocket client helper | ktor.client |
:acp-ktor-server |
Ktor server-side transport utilities | ktor.server |
:acp-ktor-test |
Test fixtures and fake transports for ACP flows | ktor.test |
:samples:kotlin-acp-client-sample |
Complete runnable client + agent reference implementation | samples |
Artifacts are published under com.agentclientprotocol. The default build version is 0.3.0-SNAPSHOT; release builds use 0.3.0.
repositories {
mavenCentral()
}
dependencies {
implementation("com.agentclientprotocol:acp:0.3.0-SNAPSHOT")
// Optional extras:
// implementation("com.agentclientprotocol:acp-ktor-client:0.3.0-SNAPSHOT")
// implementation("com.agentclientprotocol:acp-ktor-server:0.3.0-SNAPSHOT")
}Snapshot builds: When consuming the
-SNAPSHOTartifacts outside Maven Central, add the repository that hosts your snapshot (e.g. GitHub Packages or an internal mirror).
Set up an AgentSupport, wire the standard STDIO transport, and stream responses. The example below also shows how to call the optional FileSystemOperations extension so the agent can read files through the client.
import com.agentclientprotocol.agent.Agent
import com.agentclientprotocol.agent.AgentInfo
import com.agentclientprotocol.agent.AgentSession
import com.agentclientprotocol.agent.AgentSupport
import com.agentclientprotocol.agent.clientInfo
import com.agentclientprotocol.client.ClientInfo
import com.agentclientprotocol.client.FileSystemOperations
import com.agentclientprotocol.common.Event
import com.agentclientprotocol.common.SessionParameters
import com.agentclientprotocol.common.remoteSessionOperations
import com.agentclientprotocol.model.AgentCapabilities
import com.agentclientprotocol.model.ContentBlock
import com.agentclientprotocol.model.LATEST_PROTOCOL_VERSION
import com.agentclientprotocol.model.PromptResponse
import com.agentclientprotocol.model.SessionId
import com.agentclientprotocol.model.SessionUpdate
import com.agentclientprotocol.model.StopReason
import com.agentclientprotocol.protocol.Protocol
import com.agentclientprotocol.transport.StdioTransport
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonElement
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
// 1. Build a dedicated AgentSession implementation for each connection.
private class TerminalAgentSession(
override val sessionId: SessionId
) : AgentSession {
override suspend fun prompt(
content: List<ContentBlock>,
_meta: JsonElement?
): Flow<Event> = flow {
// Echo back what the user typed.
val userText = content.filterIsInstance<ContentBlock.Text>().joinToString(" ") { it.text }
emit(
Event.SessionUpdateEvent(
SessionUpdate.AgentMessageChunk(ContentBlock.Text("Agent heard: $userText"))
)
)
// Optional extension call via the coroutine context.
val context = currentCoroutineContext()
val clientCapabilities = context.clientInfo.capabilities
if (clientCapabilities.fs?.readTextFile == true) {
val fs = context.remoteSessionOperations(FileSystemOperations)
val readmeSnippet = fs.fsReadTextFile("README.md").content.take(120)
emit(
Event.SessionUpdateEvent(
SessionUpdate.AgentMessageChunk(ContentBlock.Text("README preview: $readmeSnippet…"))
)
)
}
// Finish the turn once updates are sent.
emit(Event.PromptResponseEvent(PromptResponse(StopReason.END_TURN)))
}
override suspend fun cancel() {
// No long-running work in this demo, so nothing to clean up yet.
}
}
// 2. Implement AgentSupport: negotiate capabilities and build per-session handlers.
private class TerminalAgentSupport : AgentSupport {
override suspend fun initialize(clientInfo: ClientInfo) = AgentInfo(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = AgentCapabilities() // advertise baseline agent features
)
override suspend fun createSession(sessionParameters: SessionParameters): AgentSession {
// 3. Instantiate the session implementation defined above.
val sessionId = SessionId("session-${System.currentTimeMillis()}")
return TerminalAgentSession(sessionId)
}
override suspend fun loadSession(sessionId: SessionId, sessionParameters: SessionParameters): AgentSession =
// Rehydrate existing sessions with the provided identifier.
TerminalAgentSession(sessionId)
}
fun main(): Unit = runBlocking {
// 4. Bridge STDIO to the Protocol so the agent can speak ACP over stdin/stdout.
val transport = StdioTransport(
parentScope = this,
input = System.`in`.asSource().buffered(),
output = System.out.asSink().buffered()
)
val protocol = Protocol(this, transport)
// 5. Register the agent and declare which remote extensions it will use.
Agent(
protocol = protocol,
agentSupport = TerminalAgentSupport(),
remoteSideExtensions = listOf(FileSystemOperations)
)
// 6. Start listening for messages from the client.
protocol.start()
}Create a Client with your own ClientSessionOperations implementation. This sample exposes FileSystemOperations, grants tool-call permissions, and prints streamed updates from the agent.
import com.agentclientprotocol.client.*
import com.agentclientprotocol.common.ClientSessionOperations
import com.agentclientprotocol.common.Event
import com.agentclientprotocol.common.SessionParameters
import com.agentclientprotocol.model.*
import com.agentclientprotocol.protocol.Protocol
import com.agentclientprotocol.transport.StdioTransport
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonElement
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.readText
import kotlin.io.path.writeText
// 1. Describe how the client should create session handlers for each connection.
private class TerminalClientSupport(private val projectDir: Path) : ClientSupport {
override suspend fun createClientSession(
session: ClientSession,
_sessionResponseMeta: JsonElement?
): ClientSessionOperations = TerminalSession(projectDir)
}
private class TerminalSession(
private val projectDir: Path
) : ClientSessionOperations, FileSystemOperations {
override suspend fun requestPermissions(
toolCall: SessionUpdate.ToolCallUpdate,
permissions: List<PermissionOption>,
_meta: JsonElement?
): RequestPermissionResponse =
// Grant whichever option was first in the list (swap for real UX).
RequestPermissionResponse(RequestPermissionOutcome.Selected(permissions.first().optionId))
override suspend fun notify(notification: SessionUpdate, _meta: JsonElement?) {
// Surface streaming updates back to the host application.
println("Agent update: $notification")
}
override suspend fun fsReadTextFile(
path: String,
line: UInt?,
limit: UInt?,
_meta: JsonElement?
): ReadTextFileResponse =
// Resolve file paths relative to the workspace root the client chose.
ReadTextFileResponse(projectDir.resolve(path).readText())
override suspend fun fsWriteTextFile(
path: String,
content: String,
_meta: JsonElement?
): WriteTextFileResponse {
// Allow the agent to write files through the same extension API.
projectDir.resolve(path).writeText(content)
return WriteTextFileResponse()
}
}
fun main(): Unit = runBlocking {
val transport = StdioTransport(
parentScope = this,
input = System.`in`.asSource().buffered(),
output = System.out.asSink().buffered()
)
val protocol = Protocol(this, transport)
val projectRoot = Paths.get("").toAbsolutePath()
val client = Client(
// 2. Register the client support and advertise which extensions you implement.
protocol = protocol,
clientSupport = TerminalClientSupport(projectRoot),
handlerSideExtensions = listOf(FileSystemOperations)
)
protocol.start()
client.initialize(
// 3. Negotiate capabilities so the agent knows extensions are available.
ClientInfo(
capabilities = ClientCapabilities(
fs = FileSystemCapability(readTextFile = true, writeTextFile = true)
)
)
)
val session = client.newSession(
// 4. Launch a session pointing at the project workspace.
SessionParameters(
cwd = projectRoot.toString(),
mcpServers = emptyList()
)
)
session.prompt(listOf(ContentBlock.Text("Hello agent!"))).collect { event ->
when (event) {
// 5. React to streaming updates and final responses.
is Event.SessionUpdateEvent -> println("Agent update: ${event.update}")
is Event.PromptResponseEvent -> println("Prompt finished: ${event.response.stopReason}")
}
}
}Prefer a fully wired example? Launch the repository sample that pairs the agent and client shown above:
./gradlew :samples:kotlin-acp-client-sample:run
# Gemini interactive client (requires external Gemini ACP agent)
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.GeminiClientAppKt
# Direct v2 client and agent
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.V2SimpleAgentAppKt
# Select v1 or v2 with ClientNegotiator, then run the v2 conversation
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.V2NegotiationAppKt
# Query the unstable auth/status method over v1 and v2
./gradlew :samples:kotlin-acp-client-sample:run \
-PmainClass=com.agentclientprotocol.samples.AuthStatusAppKt| Project | Shows | Command |
|---|---|---|
samples:kotlin-acp-client-sample |
End-to-end agent + client with STDIO transport | ./gradlew :samples:kotlin-acp-client-sample:run |
GeminiClientApp.kt |
Interactive CLI client that talks to an external Gemini ACP agent | ./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...GeminiClientAppKt |
V2SimpleAgentApp.kt |
Direct v2 client and agent conversation | ./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...V2SimpleAgentAppKt |
V2NegotiationApp.kt |
Client negotiation followed by a v2 conversation | ./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...V2NegotiationAppKt |
AuthStatusApp.kt |
Unstable auth/status query over v1 and v2, without a session |
./gradlew :samples:kotlin-acp-client-sample:run -PmainClass=...AuthStatusAppKt |
Each sample includes comments that explain the protocol lifecycle and can be used as templates for real applications.
The v2 samples expose a response mode through session config options. The client selects “Uppercase once”
with session/set_config_option; after replying, the agent resets to Echo and sends a config_option_update.
ClientSession.setConfigOption returns the complete option list rather than the one option that changed,
because one choice can affect the others, and later changes arrive on session.updates as
SessionUpdate.ConfigOptionUpdate — also carrying the full list.
Agents report those changes with agent.v2.ClientOperations.notify(SessionUpdate.ConfigOptionUpdate(...)),
which works while the session is idle as well as mid-turn. ACP v2 uses config options for mode selection;
dedicated modes remain available in the SDK's v1 API.
An agent can advertise the draft auth/status query with auth.status = true in its initialize response.
V1 uses agentCapabilities.auth.status. V2 uses capabilities.auth.status, independent of authMethods.
This V2 location follows the V2 authentication model; the draft RFD does not yet define a V2 schema entry.
The capability, request and response types, methods, and runtime hooks require @OptIn(UnstableApi::class).
After initialization, Client.authStatus queries the agent without a session or prompt. Agents answer through
AgentSupport.authStatus with AuthStatusResponse in V1, or StatusAuthResponse in V2.
The response can include a human-readable message and _meta. authenticated means credentials are configured, not necessarily valid.
Calls with a missing or false capability fail locally. An agent without a status handler returns JSON-RPC method not found (-32601).
AuthStatusApp.kt shows the V1 and V2 flows end to end.
AcpRequest, AcpResponse, AcpWithMeta)acp-ktor* modules)┌─────────────────┐ ┌─────────────────┐
│ Agent App │ │ Client App │
│ (AgentSupport & │ │ (ClientSupport &│
│ AgentSession) │ │ ClientSessionOps│
├─────────────────┤ ├─────────────────┤
│ Agent runtime │ │ Client runtime │
│ (`Agent`) │ │ (`Client`) │
├─────────────────┤ ├─────────────────┤
│ Protocol │ │ Protocol │
├─────────────────┤ ├─────────────────┤
│ Transport │ │ Transport │
│ (STDIO, Ktor) │◄──►│ (STDIO, Ktor) │
└─────────────────┘ └─────────────────┘
Lifecycle overview: clients establish a transport, call initialize to negotiate capabilities, open sessions (session.new), send prompts (session.prompt), and react to streamed updates (tool calls, permissions, status). Agents implement the mirrors of these methods, delegating file and permission requests back to the client when required. The Agent and Client runtime classes sit between your business logic (AgentSupport/AgentSession or ClientSupport/ClientSessionOperations) and the lower-level Protocol/transport layers.
JSON-RPC batches are supported by the shared protocol and both built-in transports.
Known limitation — JSON syntax: the SDK accepts any JSON syntax that kotlinx.serialization's Json.parseToJsonElement accepts, including some non-standard JSON such as unquoted primitive tokens. For simplicity, we rely on the library's parser and do not reimplement JSON syntax validation. Parsed values still undergo JSON-RPC envelope validation.
Contributions are welcome! Please open an issue to discuss significant changes before submitting a PR.
./gradlew check to execute the test suite.Distributed under the MIT License. See LICENSE.txt for details.