clawdforge/clients/swift/README.md
Kayos 0f091771d3 clients/swift: v0.2 multi-turn Session API
- Session actor with isolated mutable closed flag; nonisolated id/agent/createdAt
- withSession(opts) { s in ... } scoped helper auto-closes on success/throw
- createSession / listSessions / getSession on ForgeClient (extension)
- TurnEvent / TurnResult / SessionState / CreateSessionOptions models, snake_case CodingKeys
- TurnResult.text() helper concatenates "text" events
- Public memberwise inits on TurnEvent / TurnResult / SessionState (audit lesson)
- Session.description redacts client (no bearer leak via String(describing:))
- Session id URL-encoded with strict RFC 3986 unreserved set (audit lesson)
- Tests/SessionTests.swift: 13 tests covering create/turn/close-idempotent/
  withSession-on-success/withSession-on-throw/turn-after-close/text-concat/
  list/get/cross-token-404/redaction/v0.1-regression/empty-prompt
- README "Multi-turn / Sessions (v0.2)" section, withSession shown first

v0.1 surface unchanged; v0.2 is purely additive.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 07:00:56 -07:00

10 KiB

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:

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

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.

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<T>(_ 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

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.

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

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

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.

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

// 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:

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.

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.

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:

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.

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.

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.

swift build -c release
swift test

Tests

Unit tests use a URLProtocol stub — no network, no clawdforge instance required. Run:

swift test

License

MIT.