# Clawdforge — Swift SDK Swift client for [clawdforge], a LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API. - **Pure Foundation** — no `Alamofire`, no third-party HTTP libs. - **Async/await throughout** — every I/O method is `async throws`. - **Apple platforms + Linux** — same source builds on macOS/iOS/tvOS/watchOS and on Linux via `swift-corelibs-foundation`. - **MIT licensed.** ## Requirements | Platform | Minimum | |---|---| | Swift toolchain | 5.9 | | macOS | 13 | | iOS | 16 | | tvOS | 16 | | watchOS | 9 | | Linux | any distro with Swift 5.9+ | ## Install Add to your `Package.swift`: ```swift dependencies: [ .package(url: "http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git", from: "0.1.0"), ], targets: [ .target( name: "MyApp", dependencies: [ .product(name: "Clawdforge", package: "clawdforge"), ] ), ] ``` In an Xcode project: **File → Add Package Dependencies…**, paste the URL, select the `Clawdforge` library product. ## Quickstart ```swift import Clawdforge // `baseURL` must be host-only — scheme + host (+ port). A path/query/fragment // is rejected at construct with `ForgeError.invalidArgument`. let client = try ForgeClient( baseURL: URL(string: "http://192.168.0.5:8800")!, token: ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"]! ) // Health check (no token strictly required, but bearer is sent anyway). let h = try await client.healthz() print(h.claudeVersion ?? "unknown") // Run a prompt. let res = try await client.run(RunRequest( prompt: #"Reply with JSON: {"hello": "world"}"#, model: "sonnet", timeoutSecs: 60 )) print("duration: \(res.durationMs)ms") // Narrow the heterogeneous result. if case .object(let dict) = res.result, case .string(let hello) = dict["hello"] ?? .null { print(hello) // "world" } ``` ## Public API ### `ForgeClient` A `Sendable` struct. Build once, share everywhere — `URLSession` underneath is thread-safe. ```swift public struct ForgeClient: Sendable { public init(baseURL: URL, token: String, session: URLSession = .shared) throws public func healthz() async throws -> HealthStatus public func run(_ request: RunRequest) async throws -> RunResult public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken public func createToken(_ request: CreateTokenRequest) async throws -> AppToken public func listTokens() async throws -> [AppToken] public func revokeToken(name: String) async throws // v0.2 multi-turn — see "Multi-turn / Sessions (v0.2)" below. public func createSession(_ opts: CreateSessionOptions = .init()) async throws -> Session public func withSession(_ opts: CreateSessionOptions = .init(), _ block: (Session) async throws -> T) async throws -> T public func listSessions(includeClosed: Bool = true) async throws -> [SessionState] public func getSession(_ id: String) async throws -> SessionState } ``` ### File upload + reuse ```swift let png = URL(fileURLWithPath: "./recipe.png") let ft = try await client.uploadFile(at: png, ttlSecs: 3600) let res = try await client.run(RunRequest( prompt: "Extract recipe data as JSON.", files: [ft.fileToken] )) ``` The upload streams from disk via `URLSession.upload(for:fromFile:)` — no load-into-`Data()` for the file payload, so multi-megabyte files are fine. ### Admin endpoints Pass the `ADMIN_BOOTSTRAP_TOKEN` as the client's `token` to use these. ```swift let admin = try ForgeClient(baseURL: url, token: adminToken) let new = try await admin.createToken( CreateTokenRequest(name: "myapp", ipCidrs: ["10.0.0.0/8"]) ) print("save this:", new.token!) // shown ONCE let all = try await admin.listTokens() try await admin.revokeToken(name: "myapp") ``` ## Multi-turn / Sessions (v0.2) v0.2 adds a parallel `/sessions/*` surface for multi-turn conversations backed by [ACPX]. Single-turn `client.run(...)` is unchanged — the v0.2 surface is purely additive. A `Session` is an `actor` (Swift 5.5+ structured concurrency). All methods on it are `async` because the only mutable state — the `closed` flag — is guarded by actor isolation. There is no shared lock to forget, no race window between `turn()` and `close()`. ### Scoped session — recommended `withSession(_:_:)` is the canonical pattern. The closure runs against a fresh server-side session and the session is **always** closed when the closure returns — on success and on throw. Same shape as Python's `with forge.session() as s:` or Go's `defer s.Close(ctx)`. ```swift import Clawdforge let summary: String = try await client.withSession(.init(agent: "claude")) { s in let r1 = try await s.turn("Read README.md and summarize the architecture") let r2 = try await s.turn("Now describe the auth flow", files: [ft.fileToken]) return r1.text() + "\n\n" + r2.text() } // Session is closed here — even if either turn() above had thrown. ``` ### Manual lifecycle If you want the session to outlive a single function (e.g. backing a chat view-model), construct it directly and close it explicitly. `close()` is **idempotent** — safe to call from `defer` blocks even if you also call it on the success path. ```swift let s = try await client.createSession(.init(agent: "claude")) defer { Task { try? await s.close() } } // safe in defer; idempotent let r1 = try await s.turn("Read README.md and summarize") print(r1.text()) let r2 = try await s.turn("Now look at the auth flow", files: [ft.fileToken]) print(r2.text()) try await s.close() // explicit close on success ``` ### Inspecting + listing sessions ```swift // State of a specific session (404 on cross-token access). let st = try await client.getSession(s.id) print("turns so far:", st.turnCount) // All sessions for the calling token (cross-token sessions are // filtered server-side, not returned here). let mine = try await client.listSessions() for row in mine where row.closedAt == nil { print("open:", row.sessionId, "turns:", row.turnCount) } ``` ### Turn results `TurnResult` mirrors the server's structured event batch: ```swift public struct TurnResult: Codable, Sendable { public let ok: Bool public let sessionId: String public let turnIndex: Int public let events: [TurnEvent] public let stopReason: String public let durationMs: Int64 /// Concatenate every "text" event's content. Use for one-shot /// "what did the model say?" extraction. public func text() -> String } public struct TurnEvent: Codable, Sendable { public let type: String // "text", "thinking", "tool_call", ... public let content: String? // text/thinking events public let name: String? // tool_call: tool name public let args: JSONValue? // tool_call: arguments public let result: JSONValue? // tool_call: result } ``` For tool-call inspection, walk `events` directly and switch on `event.type`. ### Errors Session calls reuse `ForgeError`. The interesting cases: | Server reply | Throws | |---|---| | 404 (unknown id, cross-token id) | `.api(statusCode: 404, body: ...)` | | 410 (already closed server-side) | `.api(statusCode: 410, body: ...)` | | 502 (ACPX subprocess failure) | `.api(statusCode: 502, body: ...)` | | Calling `turn` on an already-closed `Session` | `.invalidArgument("session ... is closed")` | | `close()` after a prior successful `close()` | nothing — idempotent | ### Description / debug `Session.description` and `String(reflecting:)` deliberately exclude the embedded `ForgeClient` (and therefore the bearer token). They also exclude the `closed` flag because reading it requires `await` and a stale snapshot in `print(s)` would mislead more than help. Use `await s.isClosed()` for the live answer or `try await s.state()` for the authoritative server-side view. [ACPX]: https://github.com/openclaw/acpx ### `JSONValue` `RunResult.result` is a `JSONValue`. `claude -p --output-format json` may emit either parsed JSON or a plain string when the model didn't return JSON; `JSONValue` represents both losslessly. ```swift public enum JSONValue: Codable, Sendable, Equatable { case object([String: JSONValue]) case array([JSONValue]) case string(String) case number(Double) case bool(Bool) case null } ``` Convenience accessors: `.stringValue`, `.numberValue`, `.boolValue`, `.arrayValue`, `.objectValue` — each returns `nil` if the case doesn't match. ## Errors All thrown errors are the single `ForgeError` enum: ```swift public enum ForgeError: Error { case auth(statusCode: Int, body: String) // 401 / 403 case api(statusCode: Int, body: String) // any other non-2xx case transport(URLError) // DNS, TLS, EOF, cancellation case decoding(DecodingError) // server/client schema skew case invalidArgument(String) // SDK precondition } ``` Conforms to `LocalizedError`, so `error.localizedDescription` and SwiftUI's `.alert(error:)` modifier produce sensible messages out of the box. `/run` failures with HTTP 502 surface as `.api(statusCode: 502, body: ...)`. Decode the body as `RunFailure` to inspect `error`/`stderr`/`durationMs`/`stopReason`. ```swift do { _ = try await client.run(RunRequest(prompt: "...")) } catch let ForgeError.api(502, body) { if let data = body.data(using: .utf8), let failure = try? JSONDecoder().decode(RunFailure.self, from: data) { print("claude failed: \(failure.error)") } } ``` ## Cancellation `Task` cancellation propagates into `URLSession`. The SDK also calls `Task.checkCancellation()` before the network hop so a cancel before the request is dispatched throws `CancellationError` immediately. ```swift let handle = Task { try await client.run(RunRequest(prompt: "long prompt")) } // ... handle.cancel() ``` ## Linux Builds on Linux via `swift-corelibs-foundation`. Async `URLSession` methods (`data(for:)`, `upload(for:fromFile:)`) are available there from Swift 5.9. ```bash swift build -c release swift test ``` ## Tests Unit tests use a `URLProtocol` stub — no network, no clawdforge instance required. Run: ```bash swift test ``` ## License MIT. [clawdforge]: http://192.168.0.5:3001/Sulkta-Coop/clawdforge