A Swift SDK for Claude Code. Feature-equivalent to the official Python SDK, implemented as a thin, strongly-typed wrapper around the claude CLI.
- One-shot queries and bidirectional conversations
- Typed messages, content blocks, hooks, permissions, and control protocol
- In-process MCP servers — register Swift closures as tools Claude can call
- Session management: list / fork / rename / tag / delete transcripts from
~/.claude/projects/ - Structured JSON output via JSON schema
- Transcript mirroring to a custom
SessionStoreadapter - Real
claudeCLI smoke-tested; hermetic unit tests via/bin/shfixtures
193 tests across 45 suites. Zero test dependencies outside the Swift Testing framework and Mockable.
Upstream parity: this release tracks Python SDK
0.1.63(bundled Claude CLI 2.1.114). See CHANGELOG.md.
- macOS 15 (Sequoia) or later — uses
Foundation.Process+AsyncSequence<UInt8, Error> - Swift 6.2 toolchain
claudeCLI on$PATHwith valid auth (for real usage; tests run hermetically without it)
// Package.swift
dependencies: [
.package(url: "https://github.com/tddworks/ClaudeAgentSDK.git", from: "0.1.63")
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "ClaudeAgentSDKDomain", package: "ClaudeAgentSDK"),
.product(name: "ClaudeAgentSDKInfrastructure", package: "ClaudeAgentSDK"),
]),
]import ClaudeAgentSDKDomain
import ClaudeAgentSDKInfrastructure
var options = ClaudeAgentOptions()
options.permissionMode = .bypassPermissions
options.maxTurns = 1
for try await message in Claude.query(
prompt: "Reply with exactly the two letters OK",
options: options,
initializeOnStart: true
) {
if case let .assistant(a) = message {
print(a.text)
}
if case let .result(r) = message {
print("cost: $\(r.totalCostUsd ?? 0)")
}
}LiveConversation is @Observable and @MainActor — bind it directly to SwiftUI:
import SwiftUI
@MainActor
struct ChatView: View {
@State private var conversation = Claude.openConversation(
options: ClaudeAgentOptions(permissionMode: .bypassPermissions),
initializeOnStart: true
)
var body: some View {
List(conversation.messages, id: \.uuid) { message in
if case let .assistant(a) = message { Text(a.text) }
}
.safeAreaInset(edge: .bottom) {
TextField("Say something", text: $input)
.onSubmit {
Task { try await conversation.send(input); input = "" }
}
}
}
@State private var input = ""
}Control-plane operations are available on the gateway: setPermissionMode, setModel, interrupt, mcpStatus, contextUsage, rewindFiles, reconnectMcpServer, toggleMcpServer, stopTask, serverInfo.
The claude CLI reads session transcripts from ~/.claude/projects/<sanitized-cwd>/<session-id>.jsonl. You resume by pointing ClaudeAgentOptions at an existing session id — no state needs to live in your app.
Resume a specific session by id — options.resume (maps to --resume <id>):
var options = ClaudeAgentOptions()
options.resume = "sess-uuid-from-earlier"
for try await message in Claude.query(
prompt: "Continue where we left off.",
options: options,
initializeOnStart: true
) { /* handle message */ }Continue the most recent session in cwd — options.continueConversation (maps to --continue):
var options = ClaudeAgentOptions()
options.cwd = "/path/to/project"
options.continueConversation = trueBranch off an existing session instead of appending — combine resume with forkSession (maps to --fork-session):
var options = ClaudeAgentOptions()
options.resume = "sess-uuid"
options.forkSession = true-
From a finished run — the terminal
ResultMessagecarries it:if case let .result(r) = message { let id = r.sessionId }
-
From a live conversation —
LiveConversation.sessionIdupdates as assistant messages arrive. -
From disk —
LocalSessionLister.listSessions(directory:)returns[SDKSessionInfo]sorted most-recent first.
let lister = LocalSessionLister()
let sessions = try await lister.listSessions(directory: "/path/to/project")
guard let last = sessions.first else { return }
var options = ClaudeAgentOptions()
options.cwd = "/path/to/project"
options.resume = last.sessionId
for try await message in Claude.query(
prompt: "Summarize what we decided last time.",
options: options,
initializeOnStart: true
) {
if case let .assistant(a) = message { print(a.text) }
}Note: Python's SDK also supports
session_store-backed resume that materializes a remote-store transcript into a temporaryCLAUDE_CONFIG_DIRbefore spawning the CLI. That materialization path is not yet ported to Swift — current resume assumes the transcript lives on local disk (the default~/.claude/projects/case). Mirroring writes viaSessionStore.appendis supported; reads vialoaddefault to "not implemented".
Constrain Claude's answer to a JSON schema and stream incremental deltas:
var options = ClaudeAgentOptions()
options.permissionMode = .bypassPermissions
options.includePartialMessages = true
options.outputFormat = .jsonSchema(.object([
"type": .string("object"),
"properties": .object([
"name": .object(["type": .string("string")]),
"fruits": .object([
"type": .string("array"),
"items": .object(["type": .string("string")]),
]),
]),
"required": .array([.string("name"), .string("fruits")]),
]))
for try await message in Claude.query(
prompt: "Return a JSON object with a name and three fruits.",
options: options,
initializeOnStart: true
) {
// StreamEvent carries input_json_delta fragments as they arrive.
// The terminal AssistantMessage.toolUses[0].input holds the fully-assembled object.
if case let .assistant(a) = message,
let structured = a.toolUses.first?.input {
print(structured) // { "name": "Alice", "fruits": ["apple", ...] }
}
}Register Swift closures as MCP tools Claude can call — no subprocess, no JSON-RPC boilerplate:
let shout = McpTool(
name: "shout",
description: "Uppercase the given text",
inputSchema: .object([
"type": .string("object"),
"properties": .object(["text": .object(["type": .string("string")])]),
"required": .array([.string("text")]),
]),
handler: { args in
let text = args["text"]?.stringValue ?? ""
return .object([
"content": .array([.object([
"type": .string("text"),
"text": .string(text.uppercased()),
])]),
])
}
)
var options = ClaudeAgentOptions()
options.permissionMode = .bypassPermissions
options.allowedTools = ["mcp__local__shout"]
for try await message in Claude.query(
prompt: "Call the shout tool on 'hello' and tell me the result.",
options: options,
initializeOnStart: true,
mcpServers: ["local": SdkMcpServer(name: "local", tools: [shout])]
) { … }for try await message in Claude.query(
prompt: "Create /tmp/example.txt with the word 'hi'",
options: options,
initializeOnStart: true,
canUseTool: { toolName, input, context in
guard toolName == "Write" else { return .deny(message: "only Write allowed") }
return .allow(updatedPermissions: [
.setMode(mode: .acceptEdits, destination: .session),
])
}
) { … }Typed input for all 10 Claude Code hook events:
for try await message in Claude.query(
prompt: "Run: ls",
options: options,
initializeOnStart: true,
hookCallbacks: [
"pre-bash": { input, _ in
if case let .preToolUse(pre) = HookInput.decode(input) ?? .unknown(event: "", raw: input),
pre.toolName == "Bash" {
let command = pre.toolInput["command"]?.stringValue ?? ""
if command.contains("rm -rf") {
return .object([
"hookSpecificOutput": .object([
"hookEventName": .string("PreToolUse"),
"permissionDecision": .string("deny"),
"permissionDecisionReason": .string("dangerous"),
]),
])
}
}
return .object([:])
},
]
) { … }Browse, fork, tag, or delete local Claude Code session transcripts — no subprocess needed.
let lister = LocalSessionLister()
let sessions = try await lister.listSessions(directory: "/path/to/project")
for session in sessions { print(session.summary) }
let full = try await lister.getSessionMessages(
sessionId: sessions[0].sessionId,
directory: "/path/to/project"
)
// Rename / tag / delete
try await lister.renameSession(sessionId: id, title: "Refactor run", directory: dir)
try await lister.tagSession(sessionId: id, tag: "experiment", directory: dir)
try await lister.deleteSession(sessionId: id, directory: dir)
// Fork with fresh UUIDs and optional slice point
let fork = try await lister.forkSession(
sessionId: id,
directory: dir,
upToMessageId: "some-message-uuid",
title: "Branch A"
)actor PostgresSessionStore: SessionStore {
func append(filePath: String, entries: [JSONValue]) async throws {
// Persist to your durable store.
}
// load / listSessions / delete / listSubkeys have default throws.
}
var options = ClaudeAgentOptions()
options.enableFileCheckpointing = true
for try await message in Claude.query(
prompt: "…",
options: options,
initializeOnStart: true,
sessionStore: PostgresSessionStore()
) {
if case let .mirrorError(m) = message {
// SDK synthesizes this when store.append throws.
print("mirror failed: \(m.error)")
}
}Two layered targets following:
Sources/
├── Domain/ ← value types, aggregate protocols, ports
│ ├── Messages/ Message enum + 10 variants (user, assistant, system, result,
│ │ stream_event, rate_limit, task_started, task_progress,
│ │ task_notification, mirror_error)
│ ├── ContentBlocks/ text, thinking, tool_use, tool_result
│ ├── Options/ ClaudeAgentOptions + every knob (thinking, effort, sandbox,
│ │ output_format, betas, plugins, skills, task_budget …)
│ ├── Hooks/ HookEvent + 10 typed input structs + HookCallback typealias
│ ├── Permissions/ CanUseTool, PermissionResult, PermissionUpdate (6 variants)
│ ├── Sessions/ ProjectKey, SDKSessionInfo, SessionLister protocol,
│ │ SessionMessage, ForkSessionResult
│ ├── SessionStore/ SessionStore protocol + default-throw extensions
│ ├── Agents/ AgentDefinition (sub-agents for initialize)
│ ├── Mcp/ SdkMcpServer, McpTool, McpStatusResponse …
│ ├── Sandbox/ SandboxSettings, SandboxNetworkConfig
│ ├── Context/ ContextUsageResponse
│ ├── Query/ ClaudeAgent protocol + LiveClaudeAgent
│ ├── Session/ Conversation protocol + LiveConversation (@Observable)
│ └── Errors/ ClaudeSDKError
└── Infrastructure/ ← implementations
├── Transport/ SubprocessCLITransport (Foundation.Process + $PATH resolution)
├── Process/ CLICommandBuilder (options → argv), ProcessEnvironment
├── Protocol/ ControlLoop actor — request_id correlation, 60s timeout,
│ hook/can_use_tool/mcp_message routing, transcript mirror
├── Decoding/ MessageDecoder, JSONLineReader, McpStatusDecoder,
│ ContextUsageDecoder
├── Sessions/ LocalSessionLister (reads/writes ~/.claude/projects/)
├── Adapters/ SubprocessClaudeAgentGateway, SubprocessConversationGateway
└── Facade/ Claude.query() + Claude.openConversation()
- Correlates
request_id→CheckedContinuation<JSONValue, Error>with a 60s default timeout - Demultiplexes incoming lines:
control_response→ resolves the matching pending requestcontrol_request {subtype: "can_use_tool"}→ invokes registeredCanUseTool, replies with typedPermissionResultcontrol_request {subtype: "hook_callback"}→ dispatches to registered hook closure bycallback_idcontrol_request {subtype: "mcp_message"}→ routes JSON-RPC 2.0 (initialize, tools/list, tools/call) to in-processSdkMcpServertranscript_mirror→ forwards entries toSessionStore.append; synthesizesMirrorErrorMessageon failure- Everything else → parses via
MessageDecoderand yields to consumers
- Graceful close cascades errors to all pending requests
Session transcripts follow the same layout as the Python SDK and the claude CLI:
$CLAUDE_CONFIG_DIR (default ~/.claude)
└── projects/
└── <sanitized-project-path>/
├── <session-uuid>.jsonl ← transcript entries, one JSON per line
└── <session-uuid>/ ← optional subagent transcripts
└── subagents/…
ProjectKey.sanitize(_:) replaces non-alphanumerics with hyphens and appends a djb2 hash for paths > 200 chars — identical to Python's _sanitize_path.
swift test # 193 hermetic tests, ~1.5s
CLAUDE_SDK_SMOKE_TEST=1 swift test # + real `claude` CLI smoke test (~11s)Tests use Swift Testing (@Suite, @Test, #expect), not XCTest.
- Chicago-school / state-based: tests assert on state and return values, not on mock call counts
- Ports (
ConversationGateway,ClaudeAgentGateway,Transport,SessionStore,SessionLister,Conversation,ClaudeAgent) are@Mockable— integration tests stub them with one-linergiven(mock).foo().willReturn(bar)setups - End-to-end subprocess tests use
/bin/sh -c '…'as a stand-in forclaude, scripted to emit the same stream-json payloads the real CLI produces
A full-featured CLI lives at Sources/Example/:
swift build
.build/debug/claude-sdk-example query "Reply with OK"
.build/debug/claude-sdk-example chat # interactive REPL
.build/debug/claude-sdk-example mcp # in-process MCP tool demo
.build/debug/claude-sdk-example json # streaming JSONL w/ json_schemaPipe the JSON mode through jq:
.build/debug/claude-sdk-example json | jq 'select(.type == "assistant").tool_uses[0].input'
# => { "name": "Alice", "fruits": ["apple","banana","cherry"] }| Python surface | Swift |
|---|---|
query() |
Claude.query(...) |
ClaudeSDKClient |
Claude.openConversation(...) → LiveConversation |
@tool + create_sdk_mcp_server |
McpTool(...) + SdkMcpServer(...) |
CanUseTool, PermissionResult, PermissionUpdate (6 variants) |
✅ |
| Hook callbacks (10 typed input events) | ✅ |
| Messages (7 base + 3 task lifecycle + mirror error) | ✅ |
| Rate limit events | ✅ |
| Sub-agent definitions | ✅ |
| Session management (list / info / messages / rename / tag / delete / fork) | ✅ |
| SessionStore (append / load / listSessions / delete / listSubkeys) | ✅ |
| Control protocol (10 subtypes) | ✅ |
| Options: sandbox, thinking, effort, output_format, plugins, skills, betas, setting_sources, task_budget, file_checkpointing | ✅ |
--max-buffer-size |
n/a — Python-specific |
MIT. See LICENSE.
Wire-format design tracks the Python SDK. Protocol details (control requests, JSON-RPC envelopes for MCP, transcript-mirror framing) match byte-for-byte so either SDK can talk to the same claude CLI.