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:
| Code | When it fires | Recovery |
|---|---|---|
sdk_unsupported | The SDK version is below the supported floor (400); the message names the minimum version and the upgrade command | Upgrade the SDK. |
version_mismatch | Reserved (400) — current servers don't send it; an older server may refuse an incompatible client with it | Upgrade the SDK. |
invalid_tool_config | One or more tool specs failed validation — a bad name or schema, a duplicate, an unsupported kind | The message lists every failing tool. |
model_unavailable | Unknown model id, or one not available to this workspace | Check the model id. |
instructions_too_long | instructions exceeds 131,072 characters | Shorten them, or move reference material into a skill. |
delegation_tools_unsupported | A GPT Live session with delegation client or cosmo declares tools (422) | Remove the tools, or use delegation: "responses". |
cosmo_delegation_scope_required | delegation: "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_agent | The catalog handle doesn't resolve in this workspace | Check the handle on the Agents page. |
agent_config_unavailable | The catalog agent exists but its stored configuration couldn't be loaded | Retry; if persistent, re-save the agent in the dashboard. |
greeting_too_long | A catalog agent's greeting exceeds 4000 characters after inputs substitution | Shorten the greeting or the substituted values. |
free_minutes_exhausted | The workspace's free voice grant is spent (402) | Add a payment method. See Limits. |
provider_not_entitled | The model's provider is not included in the workspace's plan (402) | Pick an included model, or upgrade the plan. |
concurrent_session_limit | The 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: falsewith 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:
| Code | Fatal | When it fires | Recovery |
|---|---|---|---|
upstream_disconnect | no | The 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_error | varies | Unexpected server-side failure | Retry. If persistent, report it with the session_id. |
invalid_message | no | Reserved 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:
| Value | When | What it carries |
|---|---|---|
SessionStartError | agent.start() did not produce a live session | The same instance start() rejects with — code, status, serverCode, detail, retryAfterSeconds |
AudioUnavailableError | The microphone would not open during start | The same instance start() rejects with — code is an AudioUnavailableErrorCode |
ErrorEvent | The server sent an error frame on a live session | The 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;codeis a closedSessionStartErrorCode(transport,invalid_response,join_failed,config,busy,entitlement,version_mismatch,voice_disabled,rejected,handshake_failed,ready_timeout) andserver_codecarries the server's own slug when it sent one.DialError— asession.dial(...)call failed;codeis a closedDialErrorCodeandserver_codecarries 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 closedcodesaying how far the attempt got —request_failed,request_rejected,invalid_response, plus a per-call extra — whileserver_codeon 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.codenames which:not_connectedfor a send (send_text,set_muted, …) beforereadyor after the session ended, plusalready_started,audio_publish_already_active,video_publish_already_active,screen_share_unavailableandinvalid_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:
- The API key (or minted token) is valid and not expired.
- The key has the
realtime:startscope (the deprecatedrealtime:useumbrella implies it). - 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
errorevent in TypeScript fires withnullwhen 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
erroraxis. Readsession.state(disconnectReason: 'transport_error') or thesession_endedevent for it. - The wire
ErrorCodeis the server enum; a device or permission failure is not in it — that arrives asAudioUnavailableErrorwith anAudioUnavailableErrorCode. - A non-fatal
errornever ends the session — don't tear down on every frame. Gate teardown onfatal, thesession_endedevent, or the lifecycle reachingdisconnected.