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 executableEdit 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 runExpected 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
- Handle tool calls — the tool-call event lifecycle in Swift
- Server-side tools — opt in to Cosmo-executed tools
- Tools — the full tool model across SDKs
- Debugging — correlate logs with session IDs
Build a Python voice CLI
Install cosmo-ai-sdk, connect with CosmoRealtime → agent.start(), enable the OS mic and speaker, stream typed events, exit cleanly on Ctrl-C.
Packaging a macOS app
Embedding LiveKit's binary frameworks, the rpath, signing order, entitlements, and the microphone purpose string — what `swift build` alone does not do.