Cosmo Realtime SDK
Quickstart

Swift Quickstart

Build a voice session on macOS or iOS with CosmoRealtime in five minutes.

Prerequisites

  • macOS 13+ or iOS 16+, Swift 5.9+
  • A workspace API key with the realtime:use scope — create one in the dashboard (API keys)

Add the package

In Package.swift, add the dependency and link the target:

// Package.swift
dependencies: [
    .package(
        url: "https://github.com/socratic-ai/cosmo-swift-sdk",
        // Pre-1.0: a minor bump may break API, so pin to the minor.
        .upToNextMinor(from: "0.1.0")
    ),
],
targets: [
    .executableTarget(
        name: "MyApp",
        dependencies: [
            .product(name: "CosmoRealtime", package: "cosmo-swift-sdk"),
        ]
    ),
]

Or use Xcode: File → Add Package Dependencies and paste the URL.

Set your API key

export COSMO_API_KEY=cosmo_...

The API key scopes your workspace server-side — there is no project ID to pass.

An API key is a server-side secret — fine for a CLI or prototype on your own machine, but a distributed app must use a minted end-user token (Options.Credential.token) instead. See End-user credentials.

Start a session

One call starts the session: RealtimeSession.start performs the REST session-start and joins the LiveKit room, publishing your microphone during the join. Options holds the credential and base URL; SessionConfig is the agent — instructions, voice, greeting, tools.

import CosmoRealtime
import Foundation

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

print("Connecting…")
let session = try await RealtimeSession.start(
    .init(apiKey: apiKey, baseURL: URL(string: "https://app.askcosmo.ai")!),
    config: SessionConfig(
        instructions: "You are a terse voice assistant.",
        greeting: "Hi — how can I help?"
    )
)

Consume the event stream

Everything the server says arrives on one typed stream, session.events. No listeners to register up front — the stream buffers from session start, so nothing is missed. .sessionEnded is the terminal element and finishes the stream.

var turns: [String: String] = [:]

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):
                // Streaming deltas append; the final replaces the turn.
                // Rendering both the same way duplicates every turn in a UI.
                let role = delta.role == .user ? "user" : "assistant"
                if delta.isFinal {
                    // An empty final means the turn produced nothing — close
                    // it rather than printing a blank line.
                    if !delta.text.isEmpty { print("[\(role)] \(delta.text)") }
                    turns[role] = nil
                } else {
                    turns[role] = (turns[role] ?? "") + delta.text
                    print("[\(role)]… \(turns[role]!)")
                }
            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)
    }
}

Append-non-finals, replace-on-final is right for a voice session like this one, and the empty-final case above is the one wrinkle it still has to handle.

It is not enough on a text-only session (audio.output: false): there, a user final that arrives after the endpointer already committed an utterance carries only the remainder, so replacing on it drops the committed prefix. Read Transcripts before building a real transcript view, and prefer TranscriptReducer over hand-rolling one.

End the session

_ = readLine()  // block until Enter

await session.end()
events.cancel()

Run it

COSMO_API_KEY=cosmo_... swift run

Grant the microphone permission when prompted and say hello.

start(...) publishes the microphone during the join unless you pass micMuted: true — pass it for a push-to-talk UX, or whenever your UI presents the session as muted, and unmute later with setMuted(false).

A bare swift run binary has no app bundle, so it has no NSMicrophoneUsageDescription of its own and runs under the host terminal's microphone grant. Fine for this quickstart; a shipped .app needs its own purpose string and embedded frameworks — see Packaging a macOS app.

What you should see

Connecting…
Session ready — id: rs_...
Speak into your microphone. Press Enter to end.
[assistant] Hi — how can I help?
[user]… what's the tallest mountain
[user] what's the tallest mountain in the world
[assistant] Mount Everest, at 8,849 meters.
Session ended: client_ended

The complete runnable version of this program — including a client tool the agent can call — lives at sdks/cosmo-realtime/swift/Examples/HelloRealtime in the SDK repo.

Session methods

MethodWhat it does
send(text:)Send a text turn the agent answers.
send(context:)Give the agent context without asking it anything — no turn, no speech.
setMuted(_:)Mute or unmute the microphone.
ping()Send a keepalive ping.
end()End the session gracefully.

Next steps

On this page