A Swift implementation of the Agent Client Protocol (ACP) for building AI agent clients and servers. Ship ACP-compliant agents, clients, and transports for iOS apps, macOS tools, CLI utilities, or any Swift-based host.
Note: This SDK is a Swift port of the ACP Kotlin SDK (v0.33.0), providing equivalent functionality with Swift-native APIs and idioms. For a production client-side integration, see the Aptove iOS App — a full SwiftUI chat client built on this SDK.
ACP standardizes how AI agents and clients exchange messages, negotiate capabilities, and handle file operations. This SDK provides a Swift implementation of that spec:
- Type-safe models for every ACP message and capability
- Agent and client connection stacks (JSON-RPC over STDIO)
- HTTP/WebSocket transport support (optional module)
- Comprehensive samples demonstrating end-to-end sessions and tool calls
- Embed an ACP client in your iOS/macOS app to talk to external agents
- Build a headless automation agent that serves ACP prompts and tools
- Prototype new transports with the connection layer and model modules
- Validate your ACP integration using the supplied test utilities
| Module | Description | Main types |
|---|---|---|
ACPModel |
Pure data model for ACP messages, capabilities, and enums | JsonRpcMessage, PromptRequest, SessionUpdate |
ACP |
Core agent/client runtime with STDIO transport | Agent, Client, AgentConnection, ClientConnection, StdioTransport, ACPProtocol |
ACPHTTP |
HTTP/WebSocket transport support | WebSocketTransport |
Tip: When using the low-level
Protocolactor directly (e.g. for V2 sessions), importACPand use theACPProtocoltypealias to avoid ambiguity withObjectiveC.Protocolin files that also import Foundation.
- Swift 6.0+
- macOS 12.0+ / iOS 15.0+ / tvOS 15.0+ / watchOS 8.0+
- Xcode 16.0+ (for development)
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/aptove/swift-sdk.git", from: "0.2.2")
]Then add the desired targets as dependencies:
.target(
name: "YourTarget",
dependencies: [
.product(name: "ACPModel", package: "swift-sdk"),
.product(name: "ACP", package: "swift-sdk"),
// Optional:
// .product(name: "ACPHTTP", package: "swift-sdk"),
]
)Set up an Agent implementation, wire the standard STDIO transport, and stream responses:
import ACP
import ACPModel
import Foundation
// 1. Implement the Agent protocol
final class MyAgent: Agent, @unchecked Sendable {
var capabilities: AgentCapabilities {
AgentCapabilities(
loadSession: true,
promptCapabilities: PromptCapabilities(
audio: false,
image: false,
embeddedContext: true
)
)
}
var info: Implementation? {
Implementation(name: "MyAgent", version: "1.0.0")
}
func createSession(request: NewSessionRequest) async throws -> NewSessionResponse {
return NewSessionResponse(sessionId: SessionId())
}
func handlePrompt(request: PromptRequest, context: AgentContext) async throws -> PromptResponse {
// Extract user text from prompt
let userText = request.prompt.compactMap { block -> String? in
if case .text(let content) = block { return content.text }
return nil
}.joined(separator: " ")
// Stream response back
try await context.sendTextMessage("Agent heard: \(userText)")
// Use client capabilities if available
if context.clientCapabilities.fs?.readTextFile == true {
let file = try await context.readTextFile(path: "README.md")
try await context.sendTextMessage("README preview: \(file.content.prefix(120))...")
}
return PromptResponse(stopReason: .endTurn)
}
}
// 2. Wire up the transport and start
@main
struct AgentMain {
static func main() async throws {
let transport = StdioTransport()
let agent = MyAgent()
let connection = AgentConnection(transport: transport, agent: agent)
try await connection.start()
await connection.waitUntilComplete()
}
}Create a ClientConnection with your own Client implementation:
import ACP
import ACPModel
import Foundation
// 1. Implement the Client protocol
final class MyClient: Client, ClientSessionOperations, @unchecked Sendable {
var capabilities: ClientCapabilities {
ClientCapabilities(
fs: FileSystemCapability(readTextFile: true, writeTextFile: true),
terminal: true
)
}
var info: Implementation? {
Implementation(name: "MyClient", version: "1.0.0")
}
func onSessionUpdate(_ update: SessionUpdate) async {
print("Agent update: \(update)")
}
func onConnected() async {
print("Connected to agent")
}
func onDisconnected(error: Error?) async {
print("Disconnected")
}
// ClientSessionOperations
func requestPermissions(
toolCall: ToolCallUpdateData,
permissions: [PermissionOption],
meta: MetaField?
) async throws -> RequestPermissionResponse {
// Auto-approve first option (replace with real UX)
return RequestPermissionResponse(outcome: .selected(permissions.first!.optionId))
}
func notify(notification: SessionUpdate, meta: MetaField?) async {
print("Notification: \(notification)")
}
// FileSystemOperations
func readTextFile(path: String, line: UInt32?, limit: UInt32?, meta: MetaField?) async throws -> ReadTextFileResponse {
let content = try String(contentsOfFile: path, encoding: .utf8)
return ReadTextFileResponse(content: content)
}
func writeTextFile(path: String, content: String, meta: MetaField?) async throws -> WriteTextFileResponse {
try content.write(toFile: path, atomically: true, encoding: .utf8)
return WriteTextFileResponse()
}
}
// 2. Connect to an agent
@main
struct ClientMain {
static func main() async throws {
// Spawn agent process and wire up stdio pipes
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["my-agent"]
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
try process.run()
let transport = StdioTransport(
input: stdoutPipe.fileHandleForReading,
output: stdinPipe.fileHandleForWriting
)
let client = MyClient()
let connection = ClientConnection(transport: transport, client: client)
// Initialize connection
let agentInfo = try await connection.connect()
print("Connected to: \(agentInfo?.name ?? "agent")")
// Create session
let session = try await connection.createSession(
request: NewSessionRequest(cwd: FileManager.default.currentDirectoryPath, mcpServers: [])
)
// Send prompt
let response = try await connection.prompt(request: PromptRequest(
sessionId: session.sessionId,
prompt: [.text(TextContent(text: "Hello, agent!"))]
))
print("Response: \(response.stopReason)")
await connection.disconnect()
}
}The V2 protocol uses ACPProtocol directly instead of the high-level AgentConnection/ClientConnection wrappers. The example below runs a V2 agent and client in-process:
import ACP
import ACPModel
import Foundation
// Register V2 handlers on an ACPProtocol instance
func registerAgentHandlers(proto: ACPProtocol) async {
let info = Implementation(name: "MyV2Agent", version: "1.0.0")
await proto.onRequest(method: "initialize", requestType: V2InitializeRequest.self) { _ in
V2InitializeResponse(protocolVersion: .v2, info: info, capabilities: V2AgentCapabilities())
}
await proto.onRequest(method: "session/new", requestType: V2NewSessionRequest.self) { _ in
V2NewSessionResponse(sessionId: SessionId())
}
await proto.onRequest(method: "session/prompt", requestType: V2PromptRequest.self) { [proto] req in
let text = req.prompt.compactMap { if case .text(let t) = $0 { return t.text } else { return nil } }.joined()
// Send session updates back to the client
try await proto.sendNotification(method: "session/update", params:
V2UpdateSessionNotification(sessionId: req.sessionId, update: .stateUpdate(.running(_meta: nil))))
try await proto.sendNotification(method: "session/update", params:
V2UpdateSessionNotification(sessionId: req.sessionId, update: .agentMessageChunk(
V2ContentChunk(messageId: MessageId(rawValue: UUID().uuidString),
content: .text(V2ContentBlock.Text(text: text)), _meta: nil))))
try await proto.sendNotification(method: "session/update", params:
V2UpdateSessionNotification(sessionId: req.sessionId, update: .stateUpdate(.idle(stopReason: .endTurn, usage: nil, _meta: nil))))
return V2PromptResponse()
}
}
// Run a prompt turn and collect session updates
func sendPrompt(proto: ACPProtocol, sessionId: SessionId, text: String) async throws {
let (updates, cont) = AsyncStream<V2SessionUpdate>.makeStream()
await proto.onNotification(method: "session/update") { notification in
guard let params = notification.params,
let data = try? JSONEncoder().encode(params),
let notif = try? JSONDecoder().decode(V2UpdateSessionNotification.self, from: data) else { return }
cont.yield(notif.update)
}
let collectTask = Task {
for await update in updates {
if case .agentMessageChunk(let chunk) = update, case .text(let t) = chunk.content {
print("Agent: \(t.text)")
}
if case .stateUpdate(.idle) = update { cont.finish(); break }
}
}
_ = try await proto.request(
method: "session/prompt",
params: V2PromptRequest(sessionId: sessionId, prompt: [.text(V2ContentBlock.Text(text: text))]),
responseType: V2PromptResponse.self
)
await collectTask.value
}
// Wire up in-process agent and client
let (clientTransport, agentTransport) = InMemoryPipeTransport.createPair()
let agentProto = ACPProtocol(transport: agentTransport)
let clientProto = ACPProtocol(transport: clientTransport)
await registerAgentHandlers(proto: agentProto)
try await agentProto.start()
try await clientProto.start()
_ = try await clientProto.request(
method: "initialize",
params: V2InitializeRequest(protocolVersion: .v2, info: Implementation(name: "MyClient", version: "1.0.0")),
responseType: V2InitializeResponse.self
)
let sessionResp = try await clientProto.request(
method: "session/new",
params: V2NewSessionRequest(cwd: FileManager.default.currentDirectoryPath),
responseType: V2NewSessionResponse.self
)
try await sendPrompt(proto: clientProto, sessionId: sessionResp.sessionId, text: "Hello V2!")
await agentProto.close()
await clientProto.close()The
InMemoryPipeTransportused above is defined inSamples/V2SimpleAgentApp/main.swiftand is not part of the SDK library — copy it into your own code or use a real transport likeStdioTransport.
| Sample | Description | Command |
|---|---|---|
SimpleAgentApp |
In-process V1 agent + client demo | swift run SimpleAgentApp |
V2SimpleAgentApp |
In-process V2 agent + client demo with session config options | swift run V2SimpleAgentApp |
V2NegotiationApp |
Protocol version negotiation: client tries V2 first, falls back to V1 | swift run V2NegotiationApp |
AuthStatusApp |
Queries auth/status via both V1 and V2 protocols against a real agent |
swift run AuthStatusApp <cmd> [args] |
GeminiClientApp |
Interactive stdio client with file system and terminal support | swift run GeminiClientApp <cmd> [args] |
Run samples from the swift-sdk directory.
- ✅ Full ACP V1 and V2 protocol coverage with JSON-RPC framing
- ✅ Typed request/response wrappers for both V1 and V2
- ✅ Message correlation, error propagation
- ✅ V2:
session/new,session/prompt,session/update,session/set_config_option - ✅ V2 protocol version negotiation (client tries V2, falls back to V1)
- ✅ Capability negotiation and session lifecycle
- ✅ Prompt streaming with session updates
- ✅ Tool-call progress, execution plans, permission requests
- ✅ File-system operations via client callbacks
- ✅ Terminal operations via client callbacks
- ✅ Capability advertising and lifecycle management
- ✅ File-system and terminal helpers
- ✅ Permission request handling
- ✅ Session update listeners
- ✅ Process spawning for external agents
- ✅ Create session (
session/new) - ✅ Send prompts (
session/prompt) - ✅ Cancel prompt (
session/cancel) - ✅ Load session with message history (
session/load) - ✅ Set session config option (
session/set_config_option)⚠️ unstable - ✅ List sessions (
session/list)⚠️ unstable - ✅ Fork session (
session/fork)⚠️ unstable - ✅ Resume session (
session/resume)⚠️ unstable ⚠️ Delete session (session/delete) — V2 model types only, no runtime helper yet
- ✅ STDIO transport (pipes, file handles)
- ✅ WebSocket transport (via ACPHTTP module)
- 🚧 HTTP SSE transport (not implemented in any ACP SDK)
┌─────────────────┐ ┌─────────────────┐
│ Agent App │ │ Client App │
│ (Agent impl) │ │ (Client impl) │
├─────────────────┤ ├─────────────────┤
│ AgentConnection │ │ClientConnection │
├─────────────────┤ ├─────────────────┤
│ ACPProtocol │ │ ACPProtocol │
├─────────────────┤ ├─────────────────┤
│ Transport │◄──►│ Transport │
│ (Stdio, WebSkt) │ │ (Stdio, WebSkt) │
└─────────────────┘ └─────────────────┘
V1 lifecycle: Clients call connect() to negotiate capabilities, createSession(), prompt(), and react to streamed updates (tool calls, permissions, file ops). Agents implement the mirror of these methods.
V2 has no high-level AgentConnection/ClientConnection wrappers yet. Both sides use ACPProtocol directly, registering typed handlers and sending notifications manually:
┌──────────────────────┐ ┌───────────────────────┐
│ Agent (V2 handlers) │ │ Client (V2 requests) │
├──────────────────────┤ ├───────────────────────┤
│ ACPProtocol │ │ ACPProtocol │
├──────────────────────┤ ├───────────────────────┤
│ Transport │◄──►│ Transport │
│ (Stdio, WebSkt) │ │ (Stdio, WebSkt) │
└──────────────────────┘ └───────────────────────┘
V2 lifecycle: Agent registers handlers via onRequest(method:requestType:handler:). Client calls request(method:params:responseType:) and listens for session/update notifications via onNotification(method:). See the V2 quick start above for a working example.
The test suite and SwiftLint run automatically on every push via GitHub Actions. To run locally:
cd swift-sdk
swift test
swiftlint lint --strictRun specific test files:
swift test --filter E2EIntegrationTests
swift test --filter ACPModelTestsContributions are welcome! Please open an issue to discuss significant changes before submitting a PR.
- Fork and clone the repo.
- Run
swift testto execute the test suite. - Ensure all tests pass before submitting.
- File bugs and feature requests through GitHub Issues.
- For questions or integration help, start a discussion or reach out through the issue tracker.
Distributed under the Apache License 2.0. See LICENSE for details.
This SDK maintains feature parity with the ACP Kotlin SDK v0.33.0.