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

339 lines
10 KiB
Markdown

# 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<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
```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