Swift SDK for the Agent Client Protocol (ACP). Build Apple platform applications that communicate with AI coding agents, or create your own ACP-compliant agents.
Built for Aizen — a native macOS app for managing git worktrees and AI coding agents. Check out the source code.
- Full ACP protocol implementation over JSON-RPC/stdio
- Client and Agent (server) runtime support
- Multi-platform: macOS 12+, iOS 15+, tvOS 15+, watchOS 8+
- Pluggable transport layer (stdio, WebSocket)
- Actor-based concurrency for thread safety
- Async/await APIs with Swift Concurrency
- Streaming session updates via AsyncStream
- Built-in file system and terminal delegates
- Debug mode for inspecting raw protocol messages
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/wiedymi/swift-acp", from: "1.0.0")
]Then add the dependency to your target:
.target(
name: "YourApp",
dependencies: [
"ACP", // Core client & agent runtime
"ACPHTTP", // Optional: WebSocket transport
"ACPRegistry" // Optional: Agent discovery & installation
]
)| Package | Description |
|---|---|
ACPModel |
Platform-independent protocol types (shared by client and agent) |
ACP |
Core client and agent runtime for ACP communication |
ACPHTTP |
WebSocket transport for network-based communication |
ACPRegistry |
Agent discovery and installation from the ACP Registry |
import ACP
let client = Client()
// Launch an ACP-compatible agent
try await client.launch(agentPath: "/path/to/claude-code")
// Initialize with capabilities
let initResponse = try await client.initialize(
capabilities: ClientCapabilities(
fs: FileSystemCapabilities(readTextFile: true, writeTextFile: true),
terminal: true
)
)
// Create a session
let session = try await client.newSession(workingDirectory: "/path/to/project")
// Send a prompt
let response = try await client.sendPrompt(
sessionId: session.sessionId,
content: [.text(TextContent(text: "Explain this codebase"))]
)
// Cleanup
await client.terminate()let client = Client()
// Set delegate to handle agent requests
await client.setDelegate(myDelegate)
// Optional: enable debug mode
await client.enableDebugStream()try await client.launch(
agentPath: "/usr/local/bin/claude-code",
arguments: ["--some-flag"],
workingDirectory: "/path/to/project"
)let response = try await client.initialize(
protocolVersion: 1,
capabilities: ClientCapabilities(
fs: FileSystemCapabilities(readTextFile: true, writeTextFile: true),
terminal: true
),
clientInfo: ClientInfo(name: "MyApp", title: "My App", version: "1.0.0"),
timeout: 30.0
)
// Check agent capabilities
print("Agent: \(response.agentInfo?.name ?? "Unknown")")
print("Auth required: \(response.authMethods != nil)")if let authMethods = response.authMethods {
let authResponse = try await client.authenticate(
authMethodId: authMethods.first!.id,
credentials: ["token": "your-api-key"]
)
}let session = try await client.newSession(
workingDirectory: "/path/to/project",
mcpServers: [] // Optional MCP server configurations
)
// Access session info
print("Session ID: \(session.sessionId.value)")
print("Current mode: \(session.modes?.currentModeId ?? "default")")
print("Current model: \(session.models?.currentModelId ?? "default")")let response = try await client.sendPrompt(
sessionId: session.sessionId,
content: [
.text(TextContent(text: "Create a new Swift file"))
]
)
switch response.stopReason {
case .endTurn:
print("Agent completed")
case .maxTokens:
print("Reached token limit")
case .cancelled:
print("Request was cancelled")
default:
break
}Task {
for await notification in client.notifications {
guard notification.method == "session/update",
let params = notification.params,
let data = try? JSONEncoder().encode(params),
let update = try? JSONDecoder().decode(SessionUpdateNotification.self, from: data) else {
continue
}
switch update.update {
case .agentMessageChunk(let content):
if case .text(let text) = content {
print("Agent: \(text.text)")
}
case .toolCall(let toolCall):
print("Tool: \(toolCall.title ?? "Unknown") [\(toolCall.status)]")
case .plan(let plan):
for entry in plan.entries {
print("- \(entry.content) [\(entry.status)]")
}
case .currentModeUpdate(let mode):
print("Mode changed to: \(mode)")
default:
break
}
}
}// Change mode
try await client.setMode(sessionId: session.sessionId, modeId: "plan")
// Change model
try await client.setModel(sessionId: session.sessionId, modelId: "claude-3-opus")
// Cancel ongoing operation
try await client.cancelSession(sessionId: session.sessionId)
// Load existing session
let loaded = try await client.loadSession(sessionId: existingSessionId)await client.terminate()The delegate handles requests from the agent for file access, terminal operations, and permissions.
final class MyDelegate: ClientDelegate, Sendable {
// File System
func handleFileReadRequest(_ path: String, sessionId: String, line: Int?, limit: Int?) async throws -> ReadTextFileResponse {
let content = try String(contentsOfFile: path, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
return ReadTextFileResponse(content: content, totalLines: lines.count)
}
func handleFileWriteRequest(_ path: String, content: String, sessionId: String) async throws -> WriteTextFileResponse {
try content.write(toFile: path, atomically: true, encoding: .utf8)
return WriteTextFileResponse()
}
// Terminal
func handleTerminalCreate(command: String, sessionId: String, args: [String]?, cwd: String?, env: [EnvVariable]?, outputByteLimit: Int?) async throws -> CreateTerminalResponse {
// Create and track terminal process
let terminalId = TerminalId(UUID().uuidString)
// ... spawn process ...
return CreateTerminalResponse(terminalId: terminalId)
}
func handleTerminalOutput(terminalId: TerminalId, sessionId: String) async throws -> TerminalOutputResponse {
// Return current output buffer
return TerminalOutputResponse(output: "...", exitStatus: nil, truncated: false)
}
func handleTerminalWaitForExit(terminalId: TerminalId, sessionId: String) async throws -> WaitForExitResponse {
// Wait for process to complete
return WaitForExitResponse(exitStatus: TerminalExitStatus(exitCode: 0))
}
func handleTerminalKill(terminalId: TerminalId, sessionId: String) async throws -> KillTerminalResponse {
// Kill the process
return KillTerminalResponse()
}
func handleTerminalRelease(terminalId: TerminalId, sessionId: String) async throws -> ReleaseTerminalResponse {
// Release resources
return ReleaseTerminalResponse()
}
// Permissions
func handlePermissionRequest(request: RequestPermissionRequest) async throws -> RequestPermissionResponse {
// Show UI or auto-approve based on policy
print("Permission requested: \(request.message)")
if let options = request.options, let allowOption = options.first(where: { $0.kind == .allow }) {
return RequestPermissionResponse(outcome: PermissionOutcome(optionId: allowOption.optionId))
}
return RequestPermissionResponse(outcome: PermissionOutcome(optionId: "deny"))
}
}For simple use cases, use the built-in delegates:
let fileDelegate = FileSystemDelegate()
let terminalDelegate = TerminalDelegate()
// Compose into your delegate or use directly
let content = try await fileDelegate.handleFileReadRequest("/path/to/file", sessionId: "s1", line: nil, limit: nil)The agent sends real-time updates via notifications:
| Update Type | Description |
|---|---|
agentMessageChunk |
Streaming text from the agent |
agentThoughtChunk |
Agent's internal reasoning (if exposed) |
toolCall |
Tool invocation with status and content |
toolCallUpdate |
Updates to an existing tool call |
plan |
Task plan with entries and progress |
currentModeUpdate |
Mode changed (code, chat, plan, etc.) |
availableCommandsUpdate |
Available slash commands updated |
configOptionUpdate |
Configuration options changed |
Tool calls represent agent actions like reading files, running commands, or editing code:
case .toolCall(let toolCall):
print("Tool: \(toolCall.title ?? "")")
print("Kind: \(toolCall.kind?.rawValue ?? "unknown")")
print("Status: \(toolCall.status)")
// Tool kinds: read, edit, execute, search, delete, think, fetch, plan, switchMode, exitPlanMode, other
for content in toolCall.content {
switch content {
case .content(let block):
// ContentBlock (text, image)
case .diff(let diff):
print("Modified: \(diff.path)")
case .terminal(let term):
print("Terminal: \(term.terminalId)")
}
}
if let locations = toolCall.locations {
for loc in locations {
print("Location: \(loc.path):\(loc.line ?? 0)")
}
}Enable debug streaming to inspect raw JSON-RPC messages:
await client.enableDebugStream()
Task {
guard let stream = await client.debugMessages else { return }
for await message in stream {
let direction = message.direction == .outgoing ? "→" : "←"
let method = message.method ?? "response"
print("\(direction) \(method): \(message.jsonString ?? "")")
}
}
// Later: disable debug mode
await client.disableDebugStream()do {
let response = try await client.sendPrompt(sessionId: session.sessionId, content: [...])
} catch ClientError.processNotRunning {
print("Agent process is not running")
} catch ClientError.processFailed(let exitCode) {
print("Agent exited with code: \(exitCode)")
} catch ClientError.requestTimeout {
print("Request timed out")
} catch ClientError.agentError(let rpcError) {
print("Agent error: \(rpcError.message) (code: \(rpcError.code))")
} catch ClientError.delegateNotSet {
print("No delegate set to handle agent requests")
} catch ClientError.invalidResponse {
print("Invalid response from agent")
}Pass MCP (Model Context Protocol) servers when creating a session:
let session = try await client.newSession(
workingDirectory: "/project",
mcpServers: [
.stdio(StdioServerConfig(
name: "my-mcp-server",
command: "/path/to/server",
args: ["--port", "3000"],
env: [EnvVariable(name: "API_KEY", value: "...")]
)),
.http(HTTPServerConfig(
name: "remote-server",
url: "https://api.example.com/mcp",
headers: [HTTPHeader(name: "Authorization", value: "Bearer ...")]
))
]
)The SDK supports building ACP-compliant agents that can be invoked by clients.
import ACP
// Create transport and agent
let transport = StdinTransport()
let agent = Agent(transport: transport)
// Implement the delegate
final class MyAgentDelegate: AgentDelegate, Sendable {
let agent: Agent
init(agent: Agent) {
self.agent = agent
}
func handleInitialize(_ request: InitializeRequest) async throws -> InitializeResponse {
return InitializeResponse(
protocolVersion: 1,
agentCapabilities: AgentCapabilities(),
agentInfo: AgentInfo(name: "MyAgent", version: "1.0.0")
)
}
func handleNewSession(_ request: NewSessionRequest) async throws -> NewSessionResponse {
return NewSessionResponse(sessionId: SessionId(UUID().uuidString))
}
func handlePrompt(_ request: SessionPromptRequest) async throws -> SessionPromptResponse {
// Send streaming updates
try await agent.sendMessageChunk(sessionId: request.sessionId, text: "Processing...")
// Return final response
return SessionPromptResponse(stopReason: .endTurn)
}
func handleCancel(_ sessionId: SessionId) async throws {
// Handle cancellation
}
}
// Start the agent
await agent.setDelegate(MyAgentDelegate(agent: agent))
await transport.start()
await agent.start()For network-based communication, use the ACPHTTP module:
import ACPHTTP
// Connect to a WebSocket server
let transport = WebSocketTransport(url: URL(string: "ws://localhost:8080")!)
try await transport.connect()
// Send and receive messages
try await transport.send(jsonData)
for await message in transport.messages {
// Handle incoming messages
}
await transport.close()The ACPRegistry module provides agent discovery and installation from the ACP Registry.
import ACPRegistry
// Fetch available agents
let registry = RegistryClient()
let agents = try await registry.agents()
for agent in agents {
print("\(agent.name) v\(agent.version)")
}
// Find a specific agent
if let claude = try await registry.agent(id: "claude-code-acp") {
print("Found: \(claude.name)")
}
// Install an agent
let installer = AgentInstaller()
let installed = try await installer.install(claude)
// Launch with ACP client
import ACP
let client = Client()
try await client.launch(
agentPath: installed.executablePath,
arguments: installed.arguments
)The registry supports three distribution methods:
| Type | Description |
|---|---|
binary |
Platform-specific executables (tar.gz, zip) |
npx |
npm packages via npx |
uvx |
Python packages via uvx |
// Check available distribution for current platform
if let method = agent.distribution.preferred(for: .current) {
switch method {
case .binary(let target):
print("Binary: \(target.archive)")
case .npx(let pkg):
print("NPX: \(pkg.package)")
case .uvx(let pkg):
print("UVX: \(pkg.package)")
}
}- macOS 12.0+, iOS 15.0+, tvOS 15.0+, watchOS 8.0+
- Swift 5.9+
Note: Process spawning (stdio transport for launching agents) is only available on macOS. Other platforms can use WebSocket transport or implement custom transports.
This SDK implements the Agent Client Protocol specification.
See the reference/ directory for:
reference/agent-client-protocol/- Protocol specificationreference/rust-sdk/- Reference Rust implementationreference/registry/- Agent registry specificationreference/typescript-sdk/- Official TypeScript SDKreference/python-sdk/- Official Python SDKreference/kotlin-sdk/- Official Kotlin SDKreference/PARSING_LOGIC_COMPARISON.md- Cross-SDK JSON-RPC parsing behavior matrix
MIT