Cosmo Realtime SDK
Guides

Build a Swift Mac CLI

SwiftPM package, RealtimeSession.start, one typed event stream via for-try-await, mic published during join, graceful teardown on Enter.

This guide walks through building a macOS command-line tool with CosmoRealtime. The binary starts a Cosmo session with one call, drains a single typed event stream, prints live transcripts, and tears down cleanly when the user presses Enter.

Base code: sdks/cosmo-realtime/swift/Examples/HelloRealtime/.

Prerequisites

  • macOS 13+, Xcode 15+
  • Swift 5.9+ (ships with Xcode 15)
  • A Cosmo API key (cosmo_…)

1. Create the Swift package

mkdir HelloRealtime && cd HelloRealtime
swift package init --name HelloRealtime --type executable

Edit Package.swift. When working inside the monorepo, depend on the SDK by path (the name: alias must match the package's own name so the product: reference resolves):

// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "HelloRealtime",
    platforms: [
        .macOS(.v13),
    ],
    dependencies: [
        .package(name: "CosmoAI", path: "../../sdks/cosmo-realtime/swift"),
    ],
    targets: [
        .executableTarget(
            name: "HelloRealtime",
            dependencies: [
                .product(name: "CosmoRealtime", package: "CosmoAI"),
            ],
            path: "Sources/HelloRealtime"
        ),
    ]
)

Consuming the SDK outside the monorepo? Swap the path dependency for the published Cosmo Realtime Swift package URL and keep the CosmoRealtime product name.

2. Read credentials and define a tool

// Sources/HelloRealtime/main.swift
import CosmoRealtime
import Foundation

let apiKey = ProcessInfo.processInfo.environment["COSMO_API_KEY"] ?? {
    fputs("error: set COSMO_API_KEY environment variable\n", stderr)
    exit(1)
}()

guard let baseURL = URL(string: "https://app.askcosmo.ai") else { exit(1) }

Client tools are declared on SessionConfig and executed locally by your handler. SessionConfig.Tool.define builds one from a typed Decodable argument struct and a schema:

struct WeatherArgs: Decodable, Sendable {
    let city: String
    let unit: Unit?
    enum Unit: String, Decodable, Sendable { case c, f }
}

let getWeather = try SessionConfig.Tool.define(
    name: "get_weather",
    description: "Current weather for a city",
    input: .object(
        properties: [
            "city": .string(description: "City name"),
            "unit": .enum(["c", "f"]),
        ],
        required: ["city"]
    )
) { (args: WeatherArgs) in
    let unit = args.unit ?? .c
    print("[tool] get_weather city=\(args.city) unit=\(unit.rawValue)")
    return ["temp": .double(unit == .c ? 21.5 : 70.7), "unit": .string(unit.rawValue)]
}

3. Start the session

One call does everything: the REST session-start plus the media-transport join, publishing the microphone during the join. There is no project ID to pass — the API key scopes the session server-side.

print("Connecting…")
let session = try await RealtimeSession.start(
    .init(apiKey: apiKey, baseURL: baseURL),
    config: SessionConfig(
        instructions: "You are a terse voice assistant.",
        tools: [getWeather]
    )
)

start(_:config:) returns once the transport is live and throws on failure (RealtimeSessionError.versionMismatch, .handshakeFailed, .sessionStartFailed, …) — you never hold a session for a run that failed to start. Await the .ready event on session.events for the agent-ready signal.

Sessions are single-attempt: a session that ends — by end(), by the server, or by a transport failure — is terminal. Start a new one to reconnect.

4. Drain the event stream

Consumption is a single typed AsyncThrowingStream. No listeners to register up front — the stream buffers from session start, so nothing is missed. .sessionEnded is always the final element, after which the sequence finishes.

let events = Task {
    do {
        for try await event in session.events {
            switch event {
            case .ready(let ready):
                print("Session ready — id: \(ready.sessionId)")
                print("Speak into your microphone. Press Enter to end.")
            case .transcript(let delta):
                let role = delta.role == .user ? "user" : "assistant"
                let marker = delta.isFinal ? " »" : "…"
                print("[\(role)]\(marker) \(delta.text)")
            case .toolCall(let call):
                print("[tool-call] \(call.name) id=\(call.toolCallId)")
            case .error(let err):
                fputs("Server error (\(err.code.rawValue)): \(err.message)\n", stderr)
            case .sessionEnded(let ended):
                print("Session ended: \(ended.reason ?? "")")
            default:
                break
            }
        }
    } catch {
        fputs("event stream error: \(error)\n", stderr)
    }
}

Unrecognized frames surface as .unknown(rawType:payload:) and never terminate the stream. The stream is single-consumer: iterate it from exactly one task.

5. Block until the user presses Enter

print("Microphone live.")

// readLine() blocks the main thread. The Swift concurrency runtime keeps
// background tasks alive while this waits.
_ = readLine()

print("Ending…")
await session.end()
events.cancel()

print("Done.")

end() publishes the wire end frame best-effort, then tears down; it is idempotent and never throws. The events task finishes on its own once .sessionEnded lands — cancelling it afterwards is just cleanup.

6. Build and run

export COSMO_API_KEY=cosmo_...
swift run

Expected output:

Connecting…
Session ready — id: sess_…
Speak into your microphone. Press Enter to end.
Microphone live.
[user]… Hello
[user] Hello Cosmo »
[assistant]… Hi there
[assistant] Hi there! How can I help? »
Ending…
Session ended: client_ended
Done.

7. Mute, text turns, and other sends

All sends live on the session and throw RealtimeSessionError.notConnected outside an active session:

// Mute or unmute — sends the wire mute frame and toggles local capture.
try await session.setMuted(true)

// Text turn instead of audio.
try await session.send(text: "Summarise the uploaded document")
try await session.send(text: "List today's tasks")

// Keep-alive; the server replies with .pong.
try await session.ping()

To join without publishing the microphone at all (push-to-talk UX), pass micMuted: true to start and unmute later with setMuted(false).

8. Server tools and per-run options

Opt in to server-executed tools by dot-namespaced name, alongside your client tools:

let session = try await RealtimeSession.start(
    .init(apiKey: apiKey, baseURL: baseURL),
    config: SessionConfig(
        voice: .init(name: "Puck"),
        instructions: "You are a research assistant.",
        tools: [getWeather, .webSearch],
        greeting: "Hi! What are we digging into today?"
    )
)

Tool specs the server refuses are echoed on the ready event's rejectedTools and the session starts without them. See Server-side tools and Tools.

9. Observe the lifecycle

session.states is an AsyncStream of transport lifecycle values (.idle, .connecting, .connected, .reconnecting, .reconnected, .disconnected(reason:)). It yields .idle on creation and finishes after the terminal .disconnected:

Task {
    for await state in session.states {
        print("[state] \(state)")
    }
}

See Reconnects for what .reconnecting means.

Next steps

On this page