Cosmo Realtime SDK
Concepts

Errors

Full ErrorCode table — when each error fires and how to recover.

Cosmo refuses a session in two different places, and the difference decides where you catch it. Anything wrong with the credential, the workspace, or the config is refused at the HTTP layer during session start, before a room exists — the SDKs surface those as start failures. Once a session is live, the server sends error frames over the data channel, each with a code, message, and fatal flag. The SDKs additionally raise typed exceptions for local failures (dialing, sends before connect).

Session-start rejections

These arrive as non-2xx REST responses to the session-start call, carried in the standard error envelope (error.type, error.code, error.message). No room is created, so you never see them as error frames. Catch them where you call start().

Each typed rejection carries a machine-readable error.code to branch on:

CodeWhen it firesRecovery
sdk_unsupportedThe SDK version is below the supported floor (400); the message names the minimum version and the upgrade commandUpgrade the SDK.
version_mismatchReserved (400) — current servers don't send it; an older server may refuse an incompatible client with itUpgrade the SDK.
invalid_tool_configOne or more tool specs failed validation — a bad name or schema, a duplicate, an unsupported kindThe message lists every failing tool.
model_unavailableUnknown model id, or one not available to this workspaceCheck the model id.
instructions_too_longinstructions exceeds 131,072 charactersShorten them, or move reference material into a skill.
delegation_tools_unsupportedA GPT Live session with delegation client or cosmo declares tools (422)Remove the tools, or use delegation: "responses".
cosmo_delegation_scope_requireddelegation: "cosmo" from a credential without the resources:read scope (403)Start the session with an API key that holds resources:read, or use client delegation.
unknown_agentThe catalog handle doesn't resolve in this workspaceCheck the handle on the Agents page.
agent_config_unavailableThe catalog agent exists but its stored configuration couldn't be loadedRetry; if persistent, re-save the agent in the dashboard.
greeting_too_longA catalog agent's greeting exceeds 4000 characters after inputs substitutionShorten the greeting or the substituted values.
free_minutes_exhaustedThe workspace's free voice grant is spent (402)Add a payment method. See Limits.
provider_not_entitledThe model's provider is not included in the workspace's plan (402)Pick an included model, or upgrade the plan.
concurrent_session_limitThe workspace already has its maximum number of live sessions (429)End a session, or retry shortly — an abandoned session (a closed or reloaded tab) frees its slot after a short window.

Three rejections carry no typed code — branch on the HTTP status instead:

  • 401 / 403 — the credential is missing, expired, invalid, lacking the required realtime:* scope, or bound to a workspace this host doesn't serve. Refresh or fix the credential; don't retry with the same one.
  • 400 — audio.output: false with a speech-to-speech-only model. Pick a different model or leave audio output on.
  • 503 — realtime voice is temporarily unavailable on this deployment. Retry with backoff.

Schema validation runs first: a malformed body — wrong types, out-of-range values, oversize fields — returns 422 validation_error before any typed rejection. The typed codes above fire on well-formed configs.

See POST /session/start for the full status-code table.

Wire error codes

These come over the data channel on a live session (ErrorCode).

The errors a running session can actually receive:

CodeFatalWhen it firesRecovery
upstream_disconnectnoThe upstream model session dropped (go-away or network error)The server attempts transparent reconnect (you may see a reconnecting event). If fatal: false, the session may continue. If fatal: true, start a new session.
internal_errorvariesUnexpected server-side failureRetry. If persistent, report it with the session_id.
invalid_messagenoReserved for malformed control messages; not currently emitted.Check SDK version. If hand-crafting messages, validate against the protocol schema.

The enum also reserves auth_failed, workspace_forbidden, voice_disabled, and version_mismatch. Those conditions reject the session start over HTTP (above) before a data channel exists, so a live session never receives them — the SDKs still map them, so they would surface as typed errors rather than unknown (see the wire-protocol code table).

What the TypeScript error axis carries

The TypeScript SDK's error event and useRealtimeError() deliver the error itself, not a summary of it. Three things reach them, and null clears the axis:

ValueWhenWhat it carries
SessionStartErroragent.start() did not produce a live sessionThe same instance start() rejects with — code, status, serverCode, detail, retryAfterSeconds
AudioUnavailableErrorThe microphone would not open during startThe same instance start() rejects with — code is an AudioUnavailableErrorCode
ErrorEventThe server sent an error frame on a live sessionThe server's own code and fatal, unchanged

The first two are RealtimeError subclasses, so instanceof RealtimeError separates them from the server's event, and each names itself in name:

import { RealtimeError } from 'cosmo-ai';

if (error instanceof RealtimeError) {
  switch (error.name) {
    case 'SessionStartError': /* error.code is a SessionStartErrorCode */ break;
    case 'AudioUnavailableError': /* error.code is an AudioUnavailableErrorCode */ break;
  }
} else {
  // The server's event: error.code is the server ErrorCode, error.fatal its fatality.
}

A transport that drops mid-session is reported on the lifecycle axis rather than here: session.state reaches disconnected with disconnectReason: 'transport_error' and the close reason in detail, and the session_ended event fires. A failure on a per-call method — a mic toggle, a dial() — reaches the caller as a rejected promise and does not latch here.

Error subscription

Every way start() can fail throws one SessionStartError, whose closed code says how far the attempt got — switch on it instead of reading HTTP status codes:

import { SessionStartError } from 'cosmo-ai';

try {
  const session = await client.agent(config).start();
} catch (err) {
  if (!(err instanceof SessionStartError)) throw err;
  switch (err.code) {
    case 'busy':
      // Concurrent-session limit — an abandoned session frees its slot
      // after a short window; offer a retry, after err.retryAfterSeconds
      // when the server sent one.
      break;
    case 'entitlement':
      // Free minutes exhausted, or the plan lacks the model's provider.
      break;
    case 'config':
      // The config was rejected — err.serverCode names the reason.
      break;
    default:
      // err.status and err.detail carry the specifics.
  }
}

The code covers the whole start sequence, not just the session-start call: transport means the request never reached the server, so nothing happened server-side and retrying is safe; invalid_response that it answered with a body the SDK could not read, so a session may already exist and retrying can orphan it; join_failed that the transport could not join the room; handshake_failed that it joined but the room closed before ready — a failed boot — with the server's pre-close error frame (code and message) on detail when one was sent; ready_timeout that the ready handshake never arrived within the wait budget. The last two tear the session down before start() rejects. A pre-ready error frame on its own never rejects start() — the close that follows carries its detail, and ready can still arrive after a frame with fatal: false.

serverCode is separate on purpose: code is this SDK's closed set and changes only when the SDK does, while serverCode is the server's own slug for why it refused — an open set to log, not to switch on.

When a rejection carries structured extras, they arrive on detail as a SessionStartRejection — the same type in every SDK. Each group of fields belongs to one serverCode, so read the group that matches: 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.

Every other backend call — mintToken, verify, usage, dial — throws an ApiError subclass, so err instanceof ApiError covers them as one family while catching MintTokenError, DialError and the rest still tells you which call failed. Each carries the same split: a closed code for how far the attempt got, and serverCode for the server's own rejection slug.

A microphone that will not open throws AudioUnavailableError, whose code is a closed AudioUnavailableErrorCode: mic_denied, mic_not_found, mic_in_use, audio_unavailable. Every SDK declares all four even though each reports only the ones its platform can tell apart — the browser names all three device failures, Swift names a refused permission and a missing device, and Python names a missing device — so a branch written against one SDK ports unchanged. In Swift the enum is String-raw, so compare against a case (error.code == .micDenied) and read .rawValue for the slug; Python's is a str enum and TypeScript's a string union, so both still compare equal to the slug. The session's mediaState.mic carries the matching state.

Once the session is live, errors arrive as error events:

import { RealtimeError } from 'cosmo-ai';

session.on('error', (error) => {
  if (error === null) return;  // null = error cleared on recovery
  console.error(`[${error.code}] ${error.message}`);

  if (error instanceof RealtimeError) {
    if (error.name === 'AudioUnavailableError' && error.code === 'mic_denied') {
      // Show mic permission instructions
    }
    return;
  }
  // A live session only receives the codes in the table above; a credential
  // or entitlement refusal never reaches here, because it happens over HTTP
  // before the data channel exists and arrives as a SessionStartError.
  if (error.fatal) {
    // The session is gone — offer a "reconnect" action that starts a new one
  } else {
    // The turn failed; the session continues. Log it and carry on.
  }
});

useRealtimeError() is the React hook equivalent:

import { useRealtimeError } from 'cosmo-ai/react';

function ErrorBanner() {
  const error = useRealtimeError();
  if (!error) return null;
  return <div role="alert">{error.message}</div>;
}
from cosmo_ai import (
    DialError,
    SessionStateError,
    ErrorEvent,
    SessionStartError,
)

try:
    async with agent.start() as session:
        async for event in session:
            if isinstance(event, ErrorEvent):
                print(f"[{event.code}] {event.message} (fatal={event.fatal})")
                if event.fatal:
                    break   # the terminal session-ended item follows
except SessionStartError as exc:
    print(f"session start rejected: [{exc.code}] {exc}")
  • SessionStartError — agent.start() did not produce a live session; code is a closed SessionStartErrorCode (transport, invalid_response, join_failed, config, busy, entitlement, version_mismatch, voice_disabled, rejected, handshake_failed, ready_timeout) and server_code carries the server's own slug when it sent one.
  • DialError — a session.dial(...) call failed; code is a closed DialErrorCode and server_code carries the server's own slug.
  • ApiError — the base all five backend-call errors share (MintTokenError, TokenSourceError, VerifyError, UsageError, DialError), so one catch covers any of them. Each carries its own closed code saying how far the attempt got — request_failed, request_rejected, invalid_response, plus a per-call extra — while server_code on the base carries the server's own rejection slug, an open set: log it, don't switch on it.
  • SessionStateError — the session can't serve the call in its current state. code names which: not_connected for a send (send_text, set_muted, …) before ready or after the session ended, plus already_started, audio_publish_already_active, video_publish_already_active, screen_share_unavailable and invalid_payload.
do {
    let session = try await agent.start()
    for try await event in session.events {
        if case .error(let e) = event {
            print("server error [\(e.code.rawValue)] \(e.message) fatal=\(e.fatal)")
            // e.fatal == true → the terminal .sessionEnded follows; start a new session
        }
    }
} catch let error as AudioUnavailableError {
    // The microphone would not open — error.code says which failure it was.
    print("microphone unavailable [\(error.code.rawValue)] \(error.message)")
} catch let error as SessionStartError {
    // error.code is a closed SessionStartErrorCode — .handshakeFailed,
    // .readyTimeout, .busy, … — and error.serverCode the server's own slug.
    print("start failed [\(error.code.rawValue)] \(error.message)")
} catch let error as any ApiError {
    // Any backend call — mint, verify, usage, dial. Catch a specific one
    // (MintTokenError, DialError, …) when you need to know which.
    print("api call failed [\(error.message)] server=\(error.serverCode ?? "-")")
}

fatal flag

fatal: true means the session is dead and can't continue — the stream finishes with its terminal session-ended item; start a new session. fatal: false means this turn or operation failed but the session itself is still alive — you may continue speaking.

Recovery recipes

401 / 403 credential rejections

These are almost always configuration errors. Check:

  1. The API key (or minted token) is valid and not expired.
  2. The key has the realtime:start scope (the deprecated realtime:use umbrella implies it).
  3. The key belongs to the workspace whose agents and tools you're referencing.

upstream_disconnect (non-fatal)

The server attempts a transparent upstream-session rotation. The client receives reconnecting on the data channel; the session and transport survive. If instead the frame was fatal: true, start a new session (optionally with resume_session_id).

version_mismatch

Reserved. Current servers don't send it; if an older server refuses the connection with it, upgrade the SDK. See Protocol compatibility.

invalid_message

If you're using a supported SDK version and not hand-crafting messages, report it as an SDK bug. If you're hand-crafting, validate your messages against the wire protocol reference.

Pitfalls

  • The error event in TypeScript fires with null when the error is cleared (for example, on the next session start). Handlers must check for null: if (error === null) return;.
  • A mid-session transport drop does not reach the TypeScript error axis. Read session.state (disconnectReason: 'transport_error') or the session_ended event for it.
  • The wire ErrorCode is the server enum; a device or permission failure is not in it — that arrives as AudioUnavailableError with an AudioUnavailableErrorCode.
  • A non-fatal error never ends the session — don't tear down on every frame. Gate teardown on fatal, the session_ended event, or the lifecycle reaching disconnected.

On this page