Swift (macOS)
A complete Automate HTTP Server client in Swift using async/await and URLSession — endpoint discovery, Codable result types, readiness gating, cancellation, and SwiftUI menu-bar app integration.
Swift 5.9+, macOS 13+. Foundation only — no packages. Works in a SwiftUI app, a command-line tool, or an Automator/Shortcuts action bundle.
Models
// CephableModels.swift
import Foundation
struct CephableBackend: Codable, Sendable {
let flavorId: String?
let accelerator: String
let cpuFallback: Bool
}
struct CephableHealth: Codable, Sendable {
let status: String
let service: String
let appVersion: String
let platform: String
let architecture: String
let workflowStatus: String
let activeRequestId: String?
let modelName: String?
let workerRunning: Bool
let busy: Bool
let workspace: String
let backend: CephableBackend?
let contextSize: Int?
/// The user's own panel runs hold the single inference slot too.
var isReady: Bool { !busy && (workflowStatus == "idle" || workflowStatus == "terminated") }
}
struct CephableStep: Codable, Sendable {
let index: Int
let title: String
let status: String
let toolName: String?
let resultSummary: String?
let producedFilePath: String?
let producedContent: String?
}
struct CephableUsage: Codable, Sendable {
let inputTokens: Int
let outputTokens: Int
let generationMs: Double
let ttftMs: Double
let tps: Double
}
struct CephableRunResult: Codable, Sendable {
let schemaVersion: Int
let requestId: String
let taskId: String?
let status: String
let answer: String
let finalAnswer: String?
let errorCode: String?
let startedAt: String
let completedAt: String
let durationMs: Int
let model: String
let appVersion: String
let backend: CephableBackend?
let steps: [CephableStep]?
let usage: CephableUsage?
var completed: Bool { status == "completed" }
/// The contract value when one was requested, otherwise the whole answer.
var value: String { finalAnswer?.isEmpty == false ? finalAnswer! : answer }
var failedSteps: [CephableStep] { (steps ?? []).filter { $0.status == "failed" } }
/// Resolved absolute paths of files and folders this run created or edited.
var producedFiles: [String] { (steps ?? []).compactMap(\.producedFilePath) }
}
struct CephableInclude: Codable, Sendable {
var steps: Bool = true
var trace: Bool = false
var events: Bool = false
}
struct CephableRunRequest: Codable, Sendable {
var prompt: String
var taskId: String?
var timeoutMs: Int = 900_000
var thinkingLevel: String?
var additionalWorkflowPrompt: String?
var answerContract: String?
var include: CephableInclude = CephableInclude()
var restrictToWorkspace: Bool = false
var continuation: Bool = false
var selectedSkillIds: [String]?
var selectedMcpServerIds: [String]?
var allowDestructiveTools: Bool = false
}
struct CephableModelOption: Codable, Sendable {
let name: String
let family: String?
let sizeCode: String?
let fileSizeMb: Int?
let supportsTools: Bool
let availableForDevice: Bool
let downloaded: Bool
let selected: Bool
var usable: Bool { supportsTools && availableForDevice && downloaded }
}
struct CephableCancelResult: Codable, Sendable {
let stopped: Bool
let mode: String
let requestId: String?
let workflowStatus: String
}
enum CephableError: LocalizedError {
/// The request never produced a run: 400, 401, 404, 409.
case request(message: String, status: Int, type: String?)
/// A run happened and did not complete.
case run(CephableRunResult)
case notFound(portRange: String)
case busyTimeout(lastStatus: String)
var isBusy: Bool {
if case .request(_, let status, _) = self { return status == 409 }
return false
}
var isUnauthorized: Bool {
if case .request(_, let status, _) = self { return status == 401 }
return false
}
var errorDescription: String? {
switch self {
case .request(let message, let status, _):
return "Cephable request failed (HTTP \(status)): \(message)"
case .run(let result):
return "Automate run \(result.status)\(result.errorCode.map { " (\($0))" } ?? "")"
case .notFound(let range):
return """
No Cephable Automate server answered on 127.0.0.1:\(range). Open Cephable and enable \
Extensions > Cephable features > Build & Extend > Automate HTTP Server.
"""
case .busyTimeout(let lastStatus):
return "Cephable stayed busy (last status: \(lastStatus))"
}
}
}
The client
// CephableAutomateClient.swift
import Foundation
actor CephableAutomateClient {
private static let basePort = 4317
private static let portAttempts = 12
private let token: String
private var endpoint: URL?
private let session: URLSession
private let decoder = JSONDecoder()
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = []
return encoder
}()
/// - Parameter endpoint: omit to discover the port Cephable actually bound.
init(token: String? = nil, endpoint: URL? = nil) throws {
guard let token = token ?? ProcessInfo.processInfo.environment["CEPHABLE_AUTOMATE_KEY"] else {
throw CephableError.request(
message: "Set CEPHABLE_AUTOMATE_KEY or pass a token", status: 0, type: nil)
}
self.token = token
self.endpoint = endpoint
// Agent runs are long; per-request timeouts are set on each URLRequest instead.
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 960
configuration.timeoutIntervalForResource = 1020
configuration.httpMaximumConnectionsPerHost = 1
self.session = URLSession(configuration: configuration)
}
// MARK: - Transport
/// Cephable moves to the next free port when its preferred one is taken, so sweep the same
/// window the app uses and confirm the service name before trusting a listener.
func resolveEndpoint() async throws -> URL {
if let endpoint { return endpoint }
for offset in 0..<Self.portAttempts {
guard let candidate = URL(string: "http://127.0.0.1:\(Self.basePort + offset)") else { continue }
var request = URLRequest(url: candidate.appendingPathComponent("health"))
request.timeoutInterval = 3
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { continue }
// A 401 still proves a server is listening here.
if http.statusCode == 401 {
throw CephableError.request(
message: "Cephable is listening on \(candidate) but rejected the access key",
status: 401, type: "authentication_error")
}
guard http.statusCode == 200,
let health = try? decoder.decode(CephableHealth.self, from: data),
health.service == "cephable-agent"
else { continue }
endpoint = candidate
return candidate
} catch let error as CephableError {
throw error
} catch {
continue // nothing listening here
}
}
throw CephableError.notFound(portRange: "\(Self.basePort)-\(Self.basePort + Self.portAttempts - 1)")
}
private func send<T: Decodable>(
_ type: T.Type,
method: String,
path: String,
body: Encodable? = nil,
timeout: TimeInterval = 60
) async throws -> T {
let base = try await resolveEndpoint()
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = method
request.timeoutInterval = timeout
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body { request.httpBody = try encoder.encode(AnyEncodable(body)) }
let (data, response) = try await session.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
// A failed run is HTTP 500 with a COMPLETE record. Only a body without schemaVersion
// means no run happened.
let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
let hasRecord = (json?["schemaVersion"] as? Int) == 1
if !(200..<300).contains(status), !hasRecord {
let error = json?["error"] as? [String: Any]
throw CephableError.request(
message: error?["message"] as? String ?? "\(path) returned HTTP \(status)",
status: status,
type: error?["type"] as? String)
}
return try decoder.decode(T.self, from: data)
}
// MARK: - Endpoints
func health() async throws -> CephableHealth {
try await send(CephableHealth.self, method: "GET", path: "health", timeout: 10)
}
func models() async throws -> [CephableModelOption] {
struct Response: Decodable { let data: [CephableModelOption] }
return try await send(Response.self, method: "GET", path: "v1/automate/models", timeout: 15).data
}
/// Pin a model for this app session. Pass no arguments to restore the app's own selection.
func selectModel(name: String? = nil, family: String? = nil, sizeCode: String? = nil) async throws
-> CephableModelOption?
{
struct Selector: Encodable {
let modelName: String?
let family: String?
let sizeCode: String?
}
struct Response: Decodable {
let selected: CephableModelOption?
let models: [CephableModelOption]
}
let response = try await send(
Response.self, method: "POST", path: "v1/automate/models/select",
body: Selector(modelName: name, family: family, sizeCode: sizeCode), timeout: 120)
return response.selected
}
/// Always safe to call, even when nothing is running.
@discardableResult
func cancel(force: Bool = false) async throws -> CephableCancelResult {
struct Body: Encodable { let force: Bool }
return try await send(
CephableCancelResult.self, method: "POST", path: "v1/automate/cancel",
body: Body(force: force), timeout: 30)
}
@discardableResult
func waitUntilReady(timeout: TimeInterval = 300) async throws -> CephableHealth {
let deadline = Date().addingTimeInterval(timeout)
var last: CephableHealth?
while Date() < deadline {
last = try await health()
if last!.isReady { return last! }
try await Task.sleep(nanoseconds: 1_000_000_000)
}
throw CephableError.busyTimeout(lastStatus: last?.workflowStatus ?? "unknown")
}
/// Run one task. Returns the record for any outcome; throws only if no run happened.
func run(_ request: CephableRunRequest) async throws -> CephableRunResult {
// Keep the client deadline above the server's so its own timeout wins, rather than
// abandoning a run that is still executing inside Cephable.
try await send(
CephableRunResult.self, method: "POST", path: "v1/runs",
body: request, timeout: TimeInterval(request.timeoutMs) / 1000 + 30)
}
func runOrThrow(_ request: CephableRunRequest) async throws -> CephableRunResult {
let result = try await run(request)
guard result.completed else { throw CephableError.run(result) }
return result
}
}
/// Lets `send` take a heterogeneous `Encodable` body without generics on every call site.
private struct AnyEncodable: Encodable {
private let encodeValue: (Encoder) throws -> Void
init(_ wrapped: any Encodable) {
encodeValue = { encoder in try wrapped.encode(to: encoder) }
}
func encode(to encoder: Encoder) throws { try encodeValue(encoder) }
}
Using it
// main.swift
import Foundation
let cephable = try CephableAutomateClient()
do {
let health = try await cephable.health()
print("Cephable \(health.appVersion) · \(health.modelName ?? "no model") · \(health.backend?.accelerator ?? "?")")
try await cephable.waitUntilReady()
var request = CephableRunRequest(prompt: "Count the PDF files in my Documents folder.")
request.taskId = "demo-1"
request.answerContract = "End with FINAL ANSWER: <integer>"
request.timeoutMs = 300_000
let result = try await cephable.runOrThrow(request)
print("answer:", result.value)
for step in result.steps ?? [] {
print(" \(step.index). \(step.title) [\(step.toolName ?? "-")] -> \(step.status)")
}
print("\(result.durationMs)ms · \(result.usage?.outputTokens ?? 0) output tokens")
} catch let error as CephableError {
switch error {
case .run(let result):
FileHandle.standardError.write(
Data("The run did not finish: \(result.status) \(result.errorCode ?? "")\n".utf8))
for step in result.failedSteps {
FileHandle.standardError.write(
Data(" \(step.toolName ?? "-"): \(step.resultSummary ?? "")\n".utf8))
}
case _ where error.isBusy:
print("Cephable is busy with another run.")
case _ where error.isUnauthorized:
print("The access key was rejected. Copy it again from Extensions.")
default:
print(error.localizedDescription)
}
exit(1)
}
Structured output with a contract
struct FolderReport: Decodable {
let fileCount: Int
let largestFile: String
}
func folderReport(_ cephable: CephableAutomateClient, folder: String) async throws -> FolderReport {
var request = CephableRunRequest(prompt: "Inspect \(folder). Count the files and identify the largest one.")
request.answerContract =
#"End your reply with one line: FINAL ANSWER: {"fileCount": <integer>, "largestFile": "<file name>"}"#
let result = try await cephable.runOrThrow(request)
// A small local model will occasionally wrap the value in a code fence.
var raw = result.value.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.hasPrefix("```") {
raw = raw
.replacingOccurrences(of: #"^```(?:json)?\s*"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\s*```$"#, with: "", options: .regularExpression)
}
guard let data = raw.data(using: .utf8),
let report = try? JSONDecoder().decode(FolderReport.self, from: data)
else {
throw CephableError.request(
message: "The assistant did not honor the contract. Answer was: \(raw)", status: 0, type: nil)
}
return report
}
Storing the key in the Keychain
Never ship the key in a bundle or read it from a plist. For a distributed app, ask the user to paste it once and store it in the Keychain.
import Security
enum CephableKeychain {
private static let service = "com.example.myapp.cephable-automate"
private static let account = "automate-access-key"
static func save(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
var attributes = query
attributes[kSecValueData as String] = Data(key.utf8)
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else {
throw CephableError.request(
message: "Keychain write failed (\(status))", status: 0, type: nil)
}
}
static func load() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
}
// let cephable = try CephableAutomateClient(token: CephableKeychain.load())
SwiftUI menu-bar app
A run takes minutes, so the UI needs a live status, a Stop button, and cancellation that stops the run rather than just the request.
import SwiftUI
@MainActor
final class AssistantModel: ObservableObject {
@Published var prompt = ""
@Published var answer = ""
@Published var statusText = "Idle"
@Published var isRunning = false
private let cephable: CephableAutomateClient?
private var task: Task<Void, Never>?
init() { cephable = try? CephableAutomateClient(token: CephableKeychain.load()) }
func run() {
guard let cephable, !isRunning else { return }
isRunning = true
statusText = "Waiting for Cephable…"
let prompt = self.prompt
task = Task {
do {
try await cephable.waitUntilReady(timeout: 60)
statusText = "Running…"
var request = CephableRunRequest(prompt: prompt)
request.restrictToWorkspace = true
request.timeoutMs = 300_000
let result = try await cephable.run(request)
answer = result.value
statusText = result.completed
? "Done in \(result.durationMs / 1000)s"
: "\(result.status): \(result.errorCode ?? "unknown")"
} catch let error as CephableError where error.isBusy {
statusText = "Cephable is busy — try again in a moment."
} catch is CancellationError {
statusText = "Stopped."
} catch {
statusText = error.localizedDescription
}
isRunning = false
}
}
func stop() {
guard let cephable else { return }
Task {
// Cancel the run inside Cephable first. Cancelling only our Task would leave the run
// holding the single inference slot.
try? await cephable.cancel(force: false)
task?.cancel()
}
}
}
struct AssistantView: View {
@StateObject private var model = AssistantModel()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Ask Cephable to do something…", text: $model.prompt, axis: .vertical)
.lineLimit(2...5)
.disabled(model.isRunning)
HStack {
Button("Run", action: model.run)
.disabled(model.isRunning || model.prompt.isEmpty)
Button("Stop", action: model.stop)
.disabled(!model.isRunning)
Spacer()
Text(model.statusText).font(.caption).foregroundStyle(.secondary)
}
if !model.answer.isEmpty {
ScrollView { Text(model.answer).textSelection(.enabled) }.frame(maxHeight: 240)
}
}
.padding()
.frame(width: 420)
}
}
Two rules for a shipped macOS app:
- Cancel the run, not just the task. Abandoning the
URLSessionrequest leaves the run executing inside Cephable and blocking the next one. Always callcancel(force:)as well. - Serialize your own callers and still handle
409. One run happens at a time, and the user can start one from Cephable's own panel while yours is queued.