- 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
339 lines
10 KiB
Markdown
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
|