Build a Swift Mac CLI
SwiftPM package, client to agent to session, one typed event stream from for-try-await, mic published during join, graceful teardown on Enter.
Build 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: examples/swift/HelloRealtime/ in the examples repo.
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 to depend on the published SDK package. The platforms: block is required — the SDK's floor is macOS 13, and a manifest without one fails to resolve against it:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "HelloRealtime",
platforms: [
.macOS(.v13),
],
dependencies: [
.package(url: "https://github.com/socratic-ai/cosmo-swift-sdk", from: "0.8.1"),
],
targets: [
.executableTarget(
name: "HelloRealtime",
dependencies: [
.product(name: "CosmoRealtime", package: "cosmo-swift-sdk"),
],
path: "Sources/HelloRealtime"
),
]
)The package: label is the repository name, cosmo-swift-sdk — that is the package's identity for a URL dependency, not the CosmoAI name inside its own manifest.
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)
}()
Client tools are declared on the agent and executed locally by your handler. clientTool 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 AgentTool.clientTool(
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
The client holds the credential, the agent describes the assistant, and start() opens one run: the REST session-start plus the media-transport join, publishing the microphone during the join. No project ID to pass — the API key scopes the session server-side.
print("Connecting…")
let client = RealtimeClient(apiKey: apiKey)
let agent = try client.agent(
instructions: "You are a terse voice assistant.",
tools: [getWeather]
)
let session = try await agent.start()start(...) returns once the session is ready — the server's handshake has landed, so every method on the returned session works immediately — and throws SessionStartError on any failure to get there, its code naming which (.versionMismatch, .handshakeFailed, .readyTimeout, .transport, …), so you never hold a session for a run that failed to start.
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's idempotent and never throws. Teardown is immediate — events still in flight are dropped, so if you need the turn's final transcript, read it before calling end(). The events task finishes on its own once .sessionEnded lands — canceling it after is just cleanup.
6. Build and run
export COSMO_API_KEY=cosmo_...
swift runExpected output:
Connecting…
Session ready — id: 3f2b8c1e-9a4d-4e7f-8b21-06d5c9a1f4e2
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 SessionStateError with code 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 agent.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 agent = try client.agent(
instructions: "You are a research assistant.",
voice: VoiceConfig(name: "Puck"),
tools: [getWeather, .webSearchTool()],
greeting: "Hi! What are we digging into today?"
)
let session = try await agent.start()Tool specs the server refuses are echoed on the ready event's rejectedTools and the session starts without them. See Server tools and Tools.
9. Observe the lifecycle
Pass an onStateChange handler to agent.start to observe every SessionState transition (.idle, .connecting, .connected, .reconnecting, .disconnected(reason:detail:)) — it fires from .idle on, so nothing that happens during the connect is missed — and read the current value anytime as await session.state:
let onStateChange: @Sendable (SessionState) -> Void = { state in
print("[state] \(state)")
}
let session = try await agent.start(onStateChange: onStateChange)See Reconnects for what .reconnecting means.
Next steps
- Handle tool calls — the tool-call event lifecycle in Swift
- Server 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 `RealtimeClient` → 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.