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.
- Transport-independent ACP protocol implementation over JSON-RPC
- Client and Agent (server) runtime support
- Multi-platform: macOS 13+, 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
AsyncThrowingStream - Stable
session/listsupport and session metadata updates - Boolean session config options and usage update decoding
- 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/agentprism/swift-acp", branch: "main")
]Then add the dependency to your target:
.target(
name: "YourApp",
dependencies: [
"ACP", // Core runtime plus macOS stdio transport
"ACPHTTP", // Optional: core runtime plus WebSocket transport
"ACPRegistry" // Optional: Agent discovery & installation
]
)| Package | Description |
|---|---|
ACPModel |
Platform-independent protocol types (shared by client and agent) |
ACPCore |
Transport-independent client and agent runtime |
ACP |
Compatibility module combining ACPCore with the macOS stdio transport |
ACPHTTP |
ACPCore plus WebSocket transport, without a subprocess linkage |
ACPRegistry |
Agent discovery and installation from the ACP Registry |
Use ACP for local stdio agents. Network-only applications can depend on ACPHTTP (and optionally ACPCore) without linking swift-subprocess.
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 authMethod = response.authMethods?.first {
let authResponse = try await client.authenticate(
authMethodId: authMethod.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 (if the agent exposes preview model selection): \(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 {
do {
for try 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?.rawValue ?? "unknown")]")
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
}
}
} catch {
print("Transport closed: \(error.localizedDescription)")
}
}// Change mode
try await client.setMode(sessionId: session.sessionId, modeId: "plan")
// Change model if the agent exposes preview model-selection support
try await client.setModel(sessionId: session.sessionId, modelId: "claude-3-opus")
// Cancel ongoing operation
try await client.cancelSession(sessionId: session.sessionId)
// Discover existing sessions when the agent supports sessionCapabilities.list
let sessions = try await client.listSessions()
// Load existing session
let loaded = try await client.loadSession(
sessionId: existingSessionId,
cwd: "/path/to/project",
mcpServers: []
)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 for tool call: \(request.toolCall.toolCallId)")
if let allowOption = request.options.first(where: { $0.kind.hasPrefix("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 |
sessionInfoUpdate |
Session title / metadata changed |
usageUpdate |
Context window / cumulative cost 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?.rawValue ?? "unknown")")
// 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()The client can call arbitrary JSON-RPC methods without knowing their vendor-specific semantics. Method names and _meta values remain opaque to the SDK.
struct CustomRequest: Codable, Sendable {
let query: String
let _meta: [String: AnyCodable]?
}
struct CustomResponse: Codable, Sendable {
let value: String
let _meta: [String: AnyCodable]?
}
let response = try await client.sendRequest(
method: "_vendor.example/capabilities",
params: CustomRequest(
query: "available capabilities",
_meta: ["vendor.example/context": AnyCodable("discovery")]
),
as: CustomResponse.self
)
print(response._meta?["vendor.example/context"]?.value as? String ?? "none")Use the overloads without params for extension methods and notifications that do not take a payload. The raw JSONRPCResponse overload exposes result and error.data as AnyCodable when the response shape is not known statically.
For network-based communication, depend on ACPHTTP instead of ACP and create the same Client with a WebSocketTransport. ACPHTTP re-exports ACPCore and does not link the stdio implementation or swift-subprocess:
import ACPHTTP
if let endpoint = URL(string: "ws://localhost:8080/acp") {
let client = Client(transport: WebSocketTransport(url: endpoint))
try await client.connect()
let response = try await client.initialize(
InitializeRequest(
protocolVersion: 1,
clientCapabilities: ClientCapabilities(
fs: FileSystemCapabilities(readTextFile: false, writeTextFile: false),
terminal: false
)
)
)
print(response.agentInfo?.name ?? "Unknown agent")
await client.terminate()
}WebSocketClient is also available as a convenience wrapper. Its connect(_:) overload accepts a complete InitializeRequest, including _meta.
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-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 (.zip, .tar.gz, .tgz, .tar.bz2, .tbz2, or raw binaries) |
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 13.0+, iOS 15.0+, tvOS 15.0+, watchOS 8.0+
- Swift 6.3+
Note: Process spawning is available only through the macOS
ACPtarget.ACPCoreandACPHTTPremain transport-independent and can be used without linking subprocess support.
This SDK implements the Agent Client Protocol specification. The checked-in schema.json is downloaded from the protocol's authoritative latest-release artifact:
https://github.com/agentclientprotocol/agent-client-protocol/releases/latest/download/schema.json
Refresh it with:
curl --fail --location \
https://github.com/agentclientprotocol/agent-client-protocol/releases/latest/download/schema.json \
--output schema.jsonSee 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
Those directories are tracked as git submodules. Refresh them with:
git submodule update --init --remote reference/agent-client-protocol reference/rust-sdk reference/registry reference/typescript-sdk reference/python-sdk reference/kotlin-sdkMIT
Originally forked from https://github.com/wiedymi/swift-acp