Sessions.swift is a separate file from ForgeClient.swift but in the same module — fileprivate blocked the cross-file call. internal (default) is the correct visibility: same-module accessible, not part of public API. Also dropped the unused @preconcurrency on Sessions.swift's Foundation imports — that file doesn't reference Sendable-warning-emitting types directly (URLSession etc), so the attribute was a no-op generating remarks. Kept @preconcurrency on ForgeClient.swift where it actually suppresses URL/URLSession/JSONEncoder/JSONDecoder Sendable warnings. Caught by crafting-table queue (job 77012573) — first real dogfood of the build farm. exit_1, log surfaced the inaccessibility error in 6s. |
||
|---|---|---|
| .. | ||
| Examples/Basic | ||
| Sources/Clawdforge | ||
| Tests/ClawdforgeTests | ||
| Package.swift | ||
| README.md | ||
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().
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).
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.