Skip to content

tddworks/ClaudeAgentSDK

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ClaudeAgentSDK

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 SessionStore adapter
  • Real claude CLI smoke-tested; hermetic unit tests via /bin/sh fixtures

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.


Requirements

  • macOS 15 (Sequoia) or later — uses Foundation.Process + AsyncSequence<UInt8, Error>
  • Swift 6.2 toolchain
  • claude CLI on $PATH with valid auth (for real usage; tests run hermetically without it)

Installation

// 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"),
    ]),
]

Quick start — one-shot query

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)")
    }
}

Interactive conversation

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.

Resuming a conversation

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 idoptions.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 cwdoptions.continueConversation (maps to --continue):

var options = ClaudeAgentOptions()
options.cwd = "/path/to/project"
options.continueConversation = true

Branch 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

Where to get the session id

  • From a finished run — the terminal ResultMessage carries it:

    if case let .result(r) = message { let id = r.sessionId }
  • From a live conversationLiveConversation.sessionId updates as assistant messages arrive.

  • From diskLocalSessionLister.listSessions(directory:) returns [SDKSessionInfo] sorted most-recent first.

End-to-end: resume the newest session in a project

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 temporary CLAUDE_CONFIG_DIR before 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 via SessionStore.append is supported; reads via load default to "not implemented".

Structured JSON output

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", ...] }
    }
}

In-process MCP tools

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])]
) {  }

Permission callback

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),
        ])
    }
) {  }

Hook callbacks

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([:])
        },
    ]
) {  }

Session management

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"
)

Transcript mirroring (custom SessionStore)

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)")
    }
}

Architecture

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()

Control protocol (ControlLoop)

  • Correlates request_idCheckedContinuation<JSONValue, Error> with a 60s default timeout
  • Demultiplexes incoming lines:
    • control_response → resolves the matching pending request
    • control_request {subtype: "can_use_tool"} → invokes registered CanUseTool, replies with typed PermissionResult
    • control_request {subtype: "hook_callback"} → dispatches to registered hook closure by callback_id
    • control_request {subtype: "mcp_message"} → routes JSON-RPC 2.0 (initialize, tools/list, tools/call) to in-process SdkMcpServer
    • transcript_mirror → forwards entries to SessionStore.append; synthesizes MirrorErrorMessage on failure
    • Everything else → parses via MessageDecoder and yields to consumers
  • Graceful close cascades errors to all pending requests

Sessions on disk

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.


Testing

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-liner given(mock).foo().willReturn(bar) setups
  • End-to-end subprocess tests use /bin/sh -c '…' as a stand-in for claude, scripted to emit the same stream-json payloads the real CLI produces

Example app

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_schema

Pipe 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"] }

Parity with the Python SDK

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

License

MIT. See LICENSE.

Acknowledgements

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.

About

Swift SDK for Claude Code — feature parity with the official Python SDK

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages