Cosmo Realtime SDK
ReleasesMigration guidesSwift

Upgrade Swift to 0.8

Every breaking change in `cosmo-swift-sdk` 0.8.0, with the replacement for each.

Breaking changes when moving cosmo-swift-sdk from v0.7.0 to v0.8.0. The changelog has the full release notes; more than one version behind, chain the pages.

  • audio.noiseCancellation takes a mode instead of a boolean — 'off', 'denoise' or 'voice_focus'. The new one is 'denoise': it removes non-speech noise and keeps every voice, which is what a microphone several people share needs. 'voice_focus' is the previous behaviour, and keeps only the speaker it judges primary — on a shared microphone that treats the second person as background and filters them out.

    true and false remain valid on the wire, so a session started by an already-published SDK version is unaffected.

    true becomes 'voice_focus', false becomes 'off'. A two-person setup that was passing true and losing the quieter speaker wants 'denoise':

    AudioConfig(noiseCancellation: .denoise)
  • Tools are built by calling a constructor on AgentTool, and every constructor returns AgentTool, so a tools: literal reads [.webSearchTool(), .drawBoxTool(onDraw:)]. The enum cases are internal: .webSearch, .examineImage, .detectObjects, .pointAtObject, .client(...), .backgroundClient(...) and .screenLocate(...) are replaced by webSearchTool(), examineImageTool(), detectObjectsTool(), pointAtObjectTool(), clientTool(...), backgroundClientTool(...) and screenLocateTool(capture:). AgentTool.define / defineBackground are renamed clientTool / backgroundClientTool, each overloading on a ToolSchema or raw parameters. AgentTool is now a struct; name and clientToolHandler stay readable on the built value.

    // before
    let agent = try client.agent(tools: [.webSearch, .client(name: "lookup", description: "…", parameters: schema, handler: handle)])
    // after
    let agent = try client.agent(tools: [.webSearchTool(), .clientTool(name: "lookup", description: "…", parameters: schema, handler: handle)])
  • The session-event types move to the top level under the cross-SDK names — the same symbols Python and TypeScript publish, with the Event postfix the event union's members carry everywhere else. RealtimeSession.Event → RealtimeSessionEvent, and the payload typealiases follow: RealtimeSession.Ready → ReadyEvent, .TranscriptDelta → TranscriptDeltaEvent, .ModelText → ModelTextEvent, .TurnComplete → TurnCompleteEvent, .ToolCall → ToolCallEvent, .ToolDispatchStarted → ToolDispatchStartedEvent, .ToolResult → ToolResultEvent, .ToolInvocation → ToolInvocationEvent, .Reconnecting → ReconnectingEvent, .UserSpeechTimeout → UserSpeechTimeoutEvent, .SessionEnded → SessionEndedEvent, .ErrorEvent → ErrorEvent, .ErrorCode → ErrorCode, .RejectedTool → RejectedTool, .ResolvedAgent → ResolvedAgent. Case names and field shapes are unchanged — only the type spellings move.

  • Passing a workspace API key (cosmo_…) as token is refused at construction in every SDK. That parameter takes a minted end-user token; a key there authenticates anyway, so the mistake used to work — and shipped the key with whatever app carried it. Pass the key as the API-key parameter, or mint a token for the user with mintToken and pass that. Acts-as-user tokens (cosmo_pat_…) are unaffected.

    // before
    RealtimeClient(.init(token: apiKey))
    // after
    RealtimeClient(apiKey: apiKey)
  • The .cosmo(CosmoEvent) wrapper case is gone: wire cosmo.usage now surfaces directly as .usage(UsageEvent) (RealtimeSession.CosmoUsage → UsageEvent), matching the event's place in the Python and TypeScript unions. if case .cosmo(.usage(let u)) becomes if case .usage(let u).

  • ToolInvocationEvent.args is now a plain [String: JSONValue] (empty when the wire omits it), replacing the generator's opaque ArgsPayload container — read arguments directly instead of digging through additionalProperties. origin is a ToolInvocationOrigin enum (.realtime / .server), keeping the wire's closed set exhaustively switchable.

  • ToolOutcome's payloads are labeled: case ok(result:), case error(message:), case denied(reason:). The declaration now names what each case carries, matching the field names Python and TypeScript already publish. Reading one is unaffected — case .ok(let result) reads the same — but constructing one positionally is not: ToolOutcome.error(text) becomes ToolOutcome.error(message: text). PostToolUseContext.init is public and takes an outcome, so a hook's own unit tests construct these and need the labels.

  • RealtimeClient.Transport.webrtc is the canonical/default room transport case, replacing the vendor-named .livekit. The deprecated .livekit alias still connects through WebRTC, but exhaustive switches must add .webrtc.

  • A client tool carries the handler that runs it: AgentTool.clientTool(name:description:parameters:handler:) no longer defaults handler to nil, and neither does the .client case. Declaring a tool this client cannot execute advertised one that failed on every invocation, which the transport layer already said it would. A tool the server invokes over RPC without ever listing it to the agent is unchanged and unaffected — that is RealtimeAgent.start(…rpcHandlers:), the register-only complement, and it is now the only way to express it.

  • model_options is gone; its provider block moves onto model, which now takes either the model string it always took or one provider block naming the provider once — a model that disagrees with its knobs is unrepresentable. The provider types drop the Options suffix (GeminiModelOptions → GeminiModel, and likewise for OpenAI, OpenAI-mini and Grok), each block's turn_detection accepts only the detectors its provider offers (Cosmo-VAD tuning moves to CosmoVadConfig on the block's cosmoVad field), and the ModelOptions enum becomes RealtimeModel, whose .id("…") case is the string form and whose block cases carry the provider's knobs. A block with no model id runs the provider's default, and .id("gemini") still selects a provider by name. The server keeps accepting model_options from existing releases — the old pair folds into model server-side — so upgrading the SDK is not coupled to a backend deploy.

    Move the block to model and fold the old model string into its model_id:

    // before
    try client.agent(model: "gemini-live", modelOptions: .gemini(temperature: 0.7))
    // after — the case is the provider
    try client.agent(model: .gemini(GeminiModel(modelId: "gemini-live", temperature: 0.7)))
  • Client settings are the initializer's parameters, so RealtimeClient.Options is gone. RealtimeClient(.init(apiKey: key)) becomes RealtimeClient(apiKey: key), and the same for token: and tokenSource:; try RealtimeClient() is unchanged. Every parameter — baseURL, connectTimeout, requestTimeout, verifyTLS — keeps its name and default, one level up. Options.Credential goes with it: the four initializers cover the same three credential forms, so nothing is lost.

  • RealtimeClient.canMint is removed. It reported whether a credential was an API key but gated nothing — mintToken always let the server rule on the credential, and it still does. A client that cannot mint raises MintTokenError with code == .missingApiKey, before the request goes out.

  • The CosmoRealtimeMint product is removed and mintToken(externalUserId:ttlSeconds:) now ships in CosmoRealtime. Drop the product from your Package.swift dependencies and delete import CosmoRealtimeMint; the method is on the same RealtimeClient and its signature is unchanged. Minting still requires an api-key credential — a client holding a minted token or a TokenSource raises MintTokenError with code == .missingApiKey before any request goes out — and it matches how the Python and TypeScript SDKs expose the same call.

  • RealtimeError is now a protocol every error in the SDK conforms to, so catch let error as RealtimeError catches them as one family — matching except RealtimeError in Python and instanceof RealtimeError in TypeScript. It replaces the enum of the same name, whose cases were unreachable: .connectTimeout was converted internally before any caller saw it, and .sessionStartFailed, .notConnected, .alreadyConnected, .screenShareUnavailable and .invalidWirePayload were never thrown at all. Catch the equivalent SDK error instead — SessionStartError for a failed start, SessionStateError for a call the session cannot serve.

    Changed: Failures that used to surface as raw LiveKit or Foundation errors now arrive as SDK errors, so the catch above covers them. A failed data publish, byte stream, or microphone toggle raises SessionStartError coded transport; unreadable or non-JSON .mcp.json raises McpError; and a SKILL.md that cannot be read or decoded, or a skills directory that cannot be listed, raises SkillError — matching what the Python SDK already did. Code matching on the underlying framework error types needs to read the SDK error instead.

  • SkillParseError is now SkillError and carries a code naming which failure it was, so a caller can tell them apart without reading the message. The codes are not_a_directory, cannot_read, missing_frontmatter, unterminated_frontmatter, malformed_frontmatter_line, duplicate_frontmatter_key, missing_description and duplicate_skill_name — a SkillErrorCode enum in Python and Swift, a union of the same values in TypeScript. The old name described only the five parsing failures, while the type has always also covered a bad path and a duplicate skill name. Match on err.code; err.message is the sentence alone, and an unreadable skills directory now raises SkillError(cannot_read) where it used to escape as a bare PermissionError.

    Breaking: Attaching skills reads the same in every SDK. Swift takes a directory through the skills: argument itself — client.agent(skills: .directory(skillsURL)) — instead of loadSkills(fromDirectory:), which is no longer public; .directory(_:) is a factory on [Skill], so inline skills are unchanged and the two compose with +. Swift's skills is optional rather than defaulting to an empty array. parseSkillMd takes defaultName directly in TypeScript rather than wrapped in an options object, and parse_skill_md is now public in Python for SKILL.md text you already hold. The skill-assembly internals — resolveSkills, skillsMenuText, buildLoadSkillTool, LoadSkillWiring, loadSkillToolName, UnknownSkillError — are no longer public in Swift; the wire name the tool registers under is unchanged, so a hook matching cosmo_sdk_load_skill keeps working.

  • Every MCP failure now raises McpError, carrying a code naming which one it was, so a caller can tell them apart without reading the message. The codes are not_a_file, cannot_read, invalid_json, missing_servers, invalid_server_entry, missing_command, invalid_args, invalid_env, invalid_cwd, duplicate_server_name, connection_failed, invalid_response, server_error and tool_error, — an McpErrorCode enum. It replaces MCPConfigError and MCPError, and covers connection and tool-call failures as well as config, so one catch let error as McpError spans the whole concept. Match on error.code; the message is the sentence alone. McpError is no longer a ValueError — a dead subprocess is no ValueError. Two classes fold into codes: McpToolError, a bare RuntimeError outside the error family, becomes tool_error, and McpExtraNotInstalledError becomes extra_not_installed. The second is breaking for anyone catching ImportError or ExtraNotInstalledError around a missing [mcp] install — catch McpError and match the code instead. ExtraNotInstalledError is removed with it: MCP was the only extra that raised it, so it was a base class for a family of none.

    Breaking: Attaching MCP servers reads the same in both SDKs. Swift takes servers through the mcp: argument itself — client.agent(mcp: .configFile(configURL)) — instead of McpRegistry, which is no longer public; .configFile(_:) is a factory on [McpStdioServer], so inline servers are unchanged and the two compose with +. catalogAgent now throws, since duplicate server names are rejected when the agent is built rather than mid-call. The MCP internals — McpRegistry, ConnectedMcp, SkippedTool, MCPToolInfo, MCPCallResult, MCPTransport, MCPTransportFactory, defaultMCPTransportFactory and parseMcpConfig — are no longer public in Swift.

    Fixed: Swift now rejects malformed .mcp.json fields it previously accepted in silence. args that is not an array, or holds a boolean or an object, raises invalid_args instead of being coerced through string conversion — a true became the argument "1". An env that is not an object of strings raises invalid_env rather than being dropped, which had launched the server without the variables it was configured with; a non-string cwd raises invalid_cwd on the same footing. Duplicate server names are now rejected in Swift as they already were in Python. In Python, an unreadable config file raises McpError(cannot_read) where it used to escape as a bare PermissionError, an invalid document is invalid_json rather than sharing one message with an unreadable one, and "args": null means absent, as it already did for env and cwd. Both SDKs run the same mcp-config-vectors.json conformance file.

    Fixed: The runtime codes now say what actually failed. A dead subprocess reports connection_failed in Python where it used to report server_error, and a reply the SDK cannot decode reports invalid_response, which nothing raised before — the three are read from the exception the mcp package raises rather than collapsed into one. In Swift, a well-formed .mcp.json whose root is not an object reports missing_servers instead of claiming the text is not valid JSON, and a config inside a directory the process cannot traverse reports cannot_read instead of not_a_file — fileExists answers false for a permission wall exactly as it does for an absent file, so the read classifies it now.

    Breaking: A number in args is accepted only when it is whole and fits in a signed 64-bit integer, and is written in decimal. 1.0 and 1 are indistinguishable once decoded and a larger integer reached the process in scientific notation, so neither had a spelling both SDKs agreed on; quote the value instead. Both SDKs also walk a document's entries in name order now, which fixes the order servers are attached in and which malformed entry is reported when more than one is bad — Python previously followed document order.

    Fixed: A .mcp.json whose bytes are not UTF-8 now raises McpError coded cannot_read in both SDKs. Python raised a bare UnicodeDecodeError, which is a ValueError rather than an OSError and so escaped the error family entirely; Swift reported not_a_file about a file that is there.

  • MintTokenError.code is now a closed MintTokenErrorCode naming what the SDK saw — request_failed, invalid_response, request_rejected or missing_api_key — and the server's own rejection slug moves to serverCode, set only when the code is request_rejected. The two were previously the same field, so code could hold either the SDK's category or anything the server sent, down to a synthetic http_<status>, with no way to tell which. Match on err.code for what happened to the request and read err.serverCode for why the server refused. Handlers comparing code against "transport_error" or against a server slug such as "auth_failed" need updating; str(err) is now the message alone, without the code: prefix.

    Breaking: A TokenSource that cannot produce a token now raises TokenSourceError rather than MintTokenError, with its own TokenSourceErrorCode — request_failed, request_rejected, invalid_response or fetcher_failed. Resolving a token source is not part of mintToken: it happens beneath every authenticated call — verify, mintToken, session start, dial and usage reads all resolve it first, and it re-resolves on expiry and after a 401 — so the failure surfaced under the name of one operation it mostly had nothing to do with. token_source_failed is gone from MintTokenErrorCode accordingly, and the four new codes say which part failed where one bucket said only that something did. A refused redirect is request_failed in every SDK — it never reached a token endpoint, so there is no rejection to report; Python previously reported it as an http_<status> rejection.

    Breaking: TypeScript gets the same two errors. MintTokenErrorCode and TokenSourceErrorCode are literal unions rather than aliases of string, both errors take { code, message, serverCode }, and a malformed TokenSource.endpoint URL now throws a TypeError rather than an SDK error — argument validation is not part of the error family, which is what the Python SDK already did.

    Breaking: Swift gets the same two errors, as structs replacing the MintTokenError enum. MintTokenError.rejected(code:detail:), .transport(message:) and .invalidResponse(message:) are gone; construct or match MintTokenError(code:message:serverCode:) with a MintTokenErrorCode instead, and expect TokenSourceError where a TokenSource fetch used to raise a mint error. mintToken now refuses a client built with a minted token or a token source before the request goes out, with code missingApiKey, rather than letting the server answer 401 — and TokenSource.custom rejects a fetcher returning an empty jwt instead of sending it as a bearer.

    // before — the enum's rejected case carried the server's slug
    if case .rejected(let code, _) = error, code == "auth_failed" { reauthenticate() }
    // after
    if error.code == .requestRejected, error.serverCode == "auth_failed" { reauthenticate() }
  • pushAudioBuffer(_:) must be called from a capture queue rather than an audio render callback because transports may synchronously convert and copy the buffer. Caller-owned audio stream start and stop operations are now serialized, so a rapid stop cannot be overtaken by an older start.

  • mintToken takes its subject unlabeled — mintToken("user-123", ttlSeconds: 3600) — matching Python and TypeScript, which pass the external user id positionally. The labeled mintToken(externalUserId:) spelling is removed; delete the label at each call site.

  • AmbienceConfig and the agent's audio.ambience field are removed from every SDK, The field never produced audible ambience on any session started through this API — it was accepted and validated, then dropped — so removing it changes no behavior. Delete ambience from your agent's audio block; the rest of the block is unchanged.

  • The five backend calls share an ApiError base — MintTokenError, TokenSourceError, VerifyError, UsageError and DialError all descend from it, so one catch covers any of them while catching a specific one still says which call it was. serverCode moves to the base, because a rejection slug belongs to whichever backend answered rather than to the call that asked.

    VerifyError, UsageError and DialError gain closed code enums in place of a bare string: request_failed, request_rejected, invalid_response, plus invalid_request on dial and usage for a call the SDK refuses to make. Where a server slug was the code, it is now serverCode and code is request_rejected. Swift gains DialError, which it did not have, and its UsageError and VerifyError become structs carrying code rather than case-carrying enums.

  • AudioUnavailableError.code is the closed AudioUnavailableErrorCode rather than a String — micDenied, micNotFound, micInUse and audioUnavailable, the same four values every SDK already reported. A switch over it is exhaustive. Comparisons against the slug no longer compile: match the case (error.code == .micDenied), and read error.code.rawValue where the string itself is wanted.

  • Hooks no longer fire for the screen-capture RPC or for caller-registered RPC methods — hooks fire for tool calls, and wire plumbing is not one. A PreToolUse hook that matched screen_capture previously observed, denied, or rewrote captures on Swift only; Python and TypeScript already behaved this way, and all three SDKs now pin the contract.

  • One CredentialsError with the closed CredentialsErrorCode replaces six spellings of the same failure — TypeScript's CredentialError (singular), Python's CredentialsError plus its NotFound, File, Expired and Mismatch subclasses, and Swift's case-carrying enum. The five resolution codes are the slugs the cross-SDK vectors already pinned; conflicting_credentials, api_key_in_token_slot and insecure_base_url cover the construction-time guards, which previously threw an untyped error.

  • Every error carries message, so except RealtimeError as e / catch let e as RealtimeError can read it without narrowing to a concrete type first. In Swift message is now a RealtimeError requirement, which a type conforming to the protocol outside the SDK must add. In Python RealtimeError and its argument-less subclasses — NotConnectedError, VideoPublishAlreadyActiveError — now take the message as their one positional argument. str(error) is unchanged, including the "code: message" form the session, dial, usage, verify and tool-schema errors render.

  • Registering a hook that cannot work now throws HookError in every SDK, with the closed HookErrorCode — malformed_matcher, invalid_hook, server_hook_not_allowed. Python raised ValueError and TypeError and TypeScript a bare Error for these, so neither was catchable as RealtimeError; in Python it is exported from cosmo_ai.hooks, beside the hooks it describes; Swift's MalformedHookMatcherError is replaced. In Python HookError is a ValueError, so an except ValueError around hook declaration keeps firing; the two cases that raised TypeError no longer do.

  • The screen-capture handler has one shape — it always receives the ScreenCaptureRequest. The zero-argument form is gone: in Python screen_locate_tool's handler must accept the request (lambda request: ...; ignore it if unneeded), and in Swift the handler type is the top-level ScreenCaptureHandler — the request-taking signature ({ request in ... } or { _ in ... }), matching the Python and TypeScript name — with the nested ScreenLocateTool.Handler and RequestHandler names removed. TypeScript already had this shape and is unchanged. Migrate deliberately: a zero-argument handler now fails at call time, and in Python the arity error's text would reach the model as the locator's spoken reason.

  • ScreenCaptureRequest no longer carries wantsElements (wants_elements in Python); the request is an empty envelope, and in Swift its initializer is init(). Capture handlers should always collect elements — the server has always requested them, so nothing changes at runtime. A handler that read the flag can simply drop the check.

  • Declaring screen_locate on the websocket transport now refuses at session start with SessionStartError (code: "config", server_code: "screen_locate_unsupported") — the locator's capture payload travels as a byte stream, a channel the single-socket carrier does not have. Previously TypeScript silently skipped registration and Python and Swift captured the screen and then failed to deliver it; now the capture handler never runs. The Python background-tools refusal gains the matching server_code: "background_tools_unsupported".

  • ErrorCode, SessionStatus, UsageStatus and CredentialKind now accept a value the server added after your package shipped, instead of failing the payload that carried it — previously an unrecognized error code cost the whole error event, and an unrecognized status failed the usage request along with the counters you asked for. Swift switches over these enums need a default or @unknown default now that they carry an unknown(String) case and are no longer @frozen; in Python both kinds are members of the enum, so use value in list(TheEnum) rather than isinstance to tell them apart. TranscriptRole is unchanged.

    // before — the enums were frozen; an exhaustive switch compiled
    switch event.code {
    case .authFailed: signIn()
    case .versionMismatch: promptUpgrade()
    // …every declared case
    }
    // after — a value the server added arrives as .unknown(String)
    switch event.code {
    case .authFailed: signIn()
    case .versionMismatch: promptUpgrade()
    default: banner(event.code.rawValue)
    }

    In Python a server-added value is an enum member like any other, so declared versus added is a list check:

  • RealtimeSessionEvent gains a case. The session now owns the coalesced transcript — read session.transcript (one TranscriptItem per turn, Identifiable by its stable id, with an isFinal flag) instead of folding the raw delta stream yourself, and the new .transcriptUpdated(TranscriptUpdatedEvent) is yielded on session.events with the complete updated list after every change, session-synthesized like .sessionEnded. A switch over the event union without a default: arm needs a new case (the forward-compatibility posture already calls for default: alongside .unknown). send(text:) now surfaces the sent text on the stream as its own closed user .transcript final (plus the update event) unless transcript: false is passed; an in-progress speech turn is unaffected. The raw .transcript delta events are otherwise unchanged.

  • Every way a start can fail now raises one SessionStartError, whose closed SessionStartErrorCode names how far the attempt got — transport, invalid_response, join_failed, config, busy, entitlement, version_mismatch, voice_disabled, rejected, handshake_failed, ready_timeout. Switch on code where you used to branch on a type or read an HTTP status, and read the server's own rejection slug from serverCode beside it. It replaces the RealtimeSessionError enum. In Python the base error's code — previously open, carrying the server's own slug or a synthetic http_<status> — closes to the enum, with the slug moving to serverCode.

    // before
    catch let error as RealtimeSessionError { … }
    // after
    catch let error as SessionStartError where error.code == .readyTimeout { retry() }
  • SessionStartError.detail is a SessionStartRejection in every SDK — the server's structured reason for refusing a start, which no SDK carried in full before. Each group of fields belongs to one server code: limit / active for concurrent_session_limit, granted_minutes / used_minutes for free_minutes_exhausted, balance_cents / top_up_path for insufficient_credits, meter / included / used / reset_at for quota_exceeded, and provider / allowed_providers / plan / upgrade_path for provider_not_entitled. A field the server adds that the SDK does not name is kept rather than dropped.

  • Calling a session method the session cannot serve now throws SessionStateError with the closed SessionStateErrorCode — not_connected, already_started, audio_publish_already_active, video_publish_already_active, screen_share_unavailable, invalid_payload. It replaces six cases of the session error enum. A send issued before ready and one issued after the session ended both report not_connected.

  • AgentTool.name, AgentTool.clientToolHandler, and AgentTool.sdkToolNamePrefix are removed — an AgentTool is construction-only. Keep the name and handler you pass at construction; the SDK registers the handler from the declaration. The reserved cosmo_sdk_ prefix is still enforced at session start, with no caller decision attached.

  • Audio that will not open throws AudioUnavailableError, whose code names the failure, where the session's own error type was thrown before. A refused microphone takes this path — the default start() publishes the mic as it joins — and so does an audio engine that will not start: no usable input format, or a converter that will not build. It reaches you the same way on both carriers, from start() and from setMuted. It is its own type, so a catch written for a start failure no longer matches it — catch RealtimeError for both, or add a second catch. Swift names mic_denied where the platform reports a refused permission and mic_not_found where it reports no usable input; an audio fault it cannot attribute is audio_unavailable rather than a transport failure.

  • The six turn-taking and reasoning enums — InterruptionSensitivity, GrokReasoningEffort, ThinkingLevel, EndOfSpeechSensitivity, SemanticEagerness and TurnDetectionMode — are declared by the SDK rather than aliased to its generated internals. Reading a case or a rawValue off one previously needed a second import CosmoRealtimeAPI, a module the package does not publish; importing CosmoRealtime alone is now enough. Case names and wire values are unchanged, so code that spells them by name compiles as before. Code that reached into the generated module — importing CosmoRealtimeAPI, or naming Components.Schemas.InterruptionSensitivity and its siblings explicitly — drops that import and uses the SDK's own type of the same name.

  • The deprecated String-returning dial(phoneNumber:callerNumber:) overload is removed; dial returns DialResult only. Read the id from result.dialId — code that used the returned string gets the identical value from result.dialId.uuidString.lowercased().

  • ErrorEvent.fatal is a plain Bool instead of Bool?. A frame that omits the field decodes as false, matching the wire default and the other SDKs. Read it directly — remove any unwrapping, ?? false, or == true around it.

  • ReadyEvent.rejectedTools is a plain [RejectedTool] instead of [RejectedTool]?. The server always reports the list — empty means nothing was rejected — and a frame that omits the field decodes as [], matching the other SDKs. Read it directly and drop any nil-handling or ?? [].

  • The screen tools' machinery leaves the public surface. The cache: overloads of screenLocateTool, screenClickElementTool and screenHighlightElementTool are removed, and ScreenCaptureCache, the ScreenLocateTool class and its rpcMethod / byteStreamTopic constants are internal — migrate by dropping the cache: argument; every screen tool shares the SDK's store automatically. ScreenCapture.context is now opaque and optional ((any Sendable)?, with elements and context defaulted in the initializer) and ScreenCaptureContext is removed — stash your own context type at capture time and cast it back in your click/highlight handler.

  • Server-sent types — the session events, CredentialInfo, SessionUsage, SessionTokenUsage and RealtimeSessionStartTimings — no longer expose member-wise initializers. They are decoded, never constructed: build a test fixture by decoding the wire JSON the server would send (JSONDecoder().decode(ReadyEvent.self, from: json)), which also validates the fixture against the wire shape. Types you construct yourself — SilenceTimeout, Say, EndCall and all agent and session configuration — are unchanged.

  • RealtimeSessionEvent gains a case — .sessionEndingSoon(SessionEndingSoonEvent), the server's session-limit warning with secondsRemaining and a stable reason slug, previously surfaced through the unknown-event fallthrough. A switch over the event union without a default: arm needs the new case (the forward-compatibility posture already calls for default: alongside .unknown). The session keeps running until .sessionEnded.

  • agent.start(...) now returns when the session is ready — the server's handshake has landed — instead of at transport join, so every session method works the moment it returns. A session whose ready handshake never arrives within 40 seconds is torn down and the start throws SessionStartError coded readyTimeout; a room that closes before ready throws it coded handshakeFailed with a synthetic status of 0, carrying the server's boot-failure error frame code and message when one preceded the close, else handshake_disconnect. Cancelling the task that awaits a start tears the session down and throws CancellationError, so Task.cancel() and SwiftUI's .task teardown abort a start cleanly. Readiness is also read from the agent's cosmo.ready participant attribute — at join and after a reconnect — so a session that joins after the agent came up still observes it.

  • Session state observation now matches the other Cosmo SDKs. Read the current value as await session.state, and pass an onStateChange: handler to agent.start to observe every transition from .idle on — the session.states stream is removed. The state vocabulary is the shared five-state machine: the distinct .reconnected case is gone (a completed recovery re-enters .connected), the type is named SessionState, and its terminal case is .disconnected(reason:detail:) carrying the same five-slug DisconnectReason the SessionEnd hook context uses, with the server's end slug or transport message in detail.

    // before
    Task { for await state in session.states { render(state) } }
    // after
    let session = try await agent.start(onStateChange: { state in render(state) })
    let current = await session.state
  • The token counters on UsageEvent and SessionTokenUsage are plain Int instead of Int?. A payload that omits a counter decodes as 0, matching the wire default and the other SDKs. Read them directly — remove any unwrapping, ?? 0, or == nil around them. SessionUsage.tokens itself stays optional: a provider that reports no token usage still yields no breakdown.

  • ToolSchemaError is now ToolDefinitionError, and it covers the whole declaration — a bad tool name and a missing or overlong description throw it too, where Python raised a bare ValueError and TypeScript a bare Error. Both are now catchable as RealtimeError like every other SDK error; in Python ToolDefinitionError is still a ValueError, so existing handling keeps working. code is the closed ToolDefinitionErrorCode rather than a string. Swift's ToolDefinitionError and ToolSchemaConsistencyCheck.Failure are folded into it, the latter as code schema_type_mismatch.

  • A tool-call validation failure reports its issues as ToolInputIssue in every SDK — path, code, constraint — where Python had raw dictionaries keyed loc/type/ctx, Swift nested the type inside the error, and TypeScript carried path as an array of segments. path is now the dotted form (address.city, items[2].sku) everywhere, the same string the INVALID_INPUT message renders.

    TypeScript exports ToolInputIssue from cosmo-ai/tool: it is the type ToolInputValidationError.issues carries, so a caller reading them has to be able to name it.