Navigate

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:

  1. Cancel the run, not just the task. Abandoning the URLSession request leaves the run executing inside Cephable and blocking the next one. Always call cancel(force:) as well.
  2. 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.