Cosmo Realtime SDK
Guides

Debugging

Read session IDs from the ready event, correlate tool calls by tool_call_id, and trace a session end-to-end.

Every session carries a stable identifier, and every tool call carries one too. Use them to correlate events across browser devtools, server logs, and the Cosmo dashboard.

Get the session ID

The server-minted session ID arrives on the ready event once the agent is live. It's the primary key for filtering server logs.

Read the session ID

import { RealtimeClient } from 'cosmo-ai';

const client = new RealtimeClient({ token });
const session = await client.agent({ /* config */ }).start();

session.on('ready', ({ sessionId, rejectedTools }) => {
  console.log('session_id:', sessionId);
  // Store this if you want to correlate with server-side logs or
  // file a support ticket.
});

If you need the ID before ready (for example, to poll per-session REST endpoints), subscribe to session_started — it fires the instant the session-start POST returns, well before the handshake. Reading session.sessionId after agent.start() resolves gets you the same value, but by then the session is already ready. Include the ID whenever you report an issue — it's how a report gets matched to server-side logs.

In a React app, the ready event fires once per session. Subscribe from an effect:

import { useEffect } from 'react';
import { useRealtimeSessionContext } from 'cosmo-ai/react';

function SessionLogger() {
  const session = useRealtimeSessionContext();

  useEffect(() => {
    if (session === null) return;
    const unsub = session.on('ready', (event) => {
      console.log('[session]', {
        sessionId: event.sessionId,
        rejectedTools: event.rejectedTools,
        maxSessionSeconds: event.maxSessionSeconds,
      });
    });
    return unsub;
  }, [session]);

  return null;
}
from cosmo_ai import RealtimeClient, ReadyEvent
import structlog

logger = structlog.get_logger(__name__)

async with RealtimeClient(api_key="cosmo_...") as client:
    async with client.agent().start() as session:
        async for event in session:
            if isinstance(event, ReadyEvent):
                logger.info(
                    "session.ready",
                    session_id=event.session_id,
                    rejected_tools=[t.name for t in event.rejected_tools],
                )

session.session_id is also available directly as soon as agent.start() returns.

for try await event in session.events {
    if case .ready(let ready) = event {
        print("session_id=\(ready.sessionId)")
    }
}

session.sessionId (optional, set once the start succeeds) is available without waiting for the event.

Track message IDs

The wire protocol defines no per-message id — cross-component correlation rides session_id (every log line, both sides) and, for tools, tool_call_id. What the Python SDK adds on top: each of its event models carries a local id field, a UUID4 hex string the SDK stamps when the message is created or parsed. Outbound messages do serialize it onto the wire, but the server ignores the field and never logs it, so it is useful only as a client-side log key. It is unique per message — use it, for example, to tie one transcript delta in your logs to the exact event your handler saw in a high-throughput image-streaming session. TypeScript and Swift events carry no such field.

To see everything one session produces rather than subscribing name by name, use the stream form — for await (const event of session) in TypeScript, async for event in session in Python, for try await event in session.events in Swift. In every SDK the stream carries the same event values the callback surface does.

from cosmo_ai import TranscriptDeltaEvent

async for event in session:
    if isinstance(event, TranscriptDeltaEvent):
        logger.info(
            "transcript.delta",
            role=event.role,
            text=event.text,
            is_final=event.is_final,
        )

Add a catch-all event logger

Log every event the session emits when you don't yet know which one you need.

Log every event

Iterate the session for the wire-level stream:

for await (const event of session) {
  console.debug('[realtime]', event.type, event);
}

Or approximate a catch-all on the callback surface by subscribing to the known event names:

const ALL_EVENTS = [
  'transport_state', 'agent_state', 'media_state', 'lifecycle',
  'transcript', 'model_text', 'tool_call', 'tool_dispatch_started', 'tool_result',
  'volume', 'error', 'ready', 'session_started', 'reconnecting',
  'session_ending_soon', 'session_ended', 'turn_complete', 'pong',
  'session_state',
  'user_speech_timeout',
  'usage',
] as const;

for (const event of ALL_EVENTS) {
  session.on(event, (payload) => {
    console.debug(`[realtime:${event}]`, payload);
  });
}

The session stream already carries every server event — log them all with one loop:

async for event in session:
    logger.debug("realtime.event", event_type=getattr(event, "type", type(event).__name__))

Unrecognized frames surface as UnknownEvent (with raw_type and the raw payload), so a catch-all logger also shows anything a newer server sends.

Correlate tool calls

Each tool-call → tool-result pair shares a tool_call_id:

from cosmo_ai import ToolCallEvent, ToolResultEvent

active_calls: dict[str, str] = {}  # tool_call_id → name

async for event in session:
    match event:
        case ToolCallEvent():
            active_calls[event.tool_call_id] = event.name
            logger.info("tool.call", name=event.name, call_id=event.tool_call_id)
        case ToolResultEvent():
            name = active_calls.pop(event.tool_call_id, "unknown")
            logger.info(
                "tool.result",
                name=name,
                call_id=event.tool_call_id,
                ok=event.ok,
                summary=event.summary,
            )

Common issues

Match the symptom you're seeing to its usual cause and fix.

transportState stuck at 'connecting'

The ready handshake hasn't arrived. The wait is bounded: agent.start() rejects with a SessionStartError coded ready_timeout after 40 seconds of silence, or handshake_failed as soon as the room closes — so read the error you get rather than waiting it out. Common causes:

  1. The credential is invalid or expired — check the rejection's code and message.
  2. The base URL is wrong — the default is https://platform.askcosmo.ai; the SDK composes the external session endpoints from it.
  3. A firewall or corporate proxy is blocking the WebRTC connection.
session.on('error', (err) => {
  if (!err) return;
  console.error('session error', err.code, err.message);
});

mic_denied error

The browser rejected getUserMedia. The user must grant microphone permission in browser settings. start() rejects with AudioUnavailableError whose code is 'mic_denied', and the same error reaches the session's error event.

rejected_tools in the ready event

Tool specs you passed on the agent's tools list were refused by the server — an unknown server-tool name, a sanitized schema, or a cap. Each entry carries the name and a reason. Double-check names against the server tools table; the session runs without the refused tools.

Turn up SDK logging

Every SDK is quiet by default. Set COSMO_LOG_LEVEL to see what the transport is doing, without changing a line of your app:

COSMO_LOG_LEVEL=debug python agent.py

The values are silent, error, warn, info, and debug. At debug each session also reports its connect-latency breakdown — the session-start round trip, the room join, the microphone publish, and the server's own share of the total — which is where a slow start usually gives itself away.

Where the lines come out differs by SDK:

  • TypeScript writes to the console. setLogLevel('debug') sets the level from code and overrides the variable; getLogLevel() reads it back. In a browser bundle there is no environment to read, so the programmatic call is the only way in.

  • Python attaches a handler to the cosmo_ai logger and writes to standard error. Without the variable the namespace stays silent until you configure it yourself:

    import logging
    
    logging.basicConfig()
    logging.getLogger("cosmo_ai").setLevel(logging.DEBUG)
  • Swift is the narrow one. The variable gates a stderr sink that today carries the connect-latency line and nothing else, because os_log levels are set outside the process. To read a Swift session in full, use its os_log records instead: log stream --predicate 'subsystem == "socratic.cosmo-realtime"' --info --debug.

Read a finished session back

Once a session ends, its transcript and recordings are retrievable from the shell:

cosmo sessions logs <session-id>            # print the transcript
cosmo sessions logs <session-id> --bundle   # zip it with the recordings
cosmo sessions timeline <session-id>        # when each turn happened

timeline is the after-the-fact counterpart to the per-turn lines above: the same question — where did the time go — answered for a session nobody was watching, from the server's side. The two measure different things and will not agree exactly. The server's clock stops at the frame it hands to the transport, so its numbers exclude the listener's playout hop, which is exactly the part COSMO_LOG_LEVEL measures and the part a caller feels. A gap between them is the network and the device, not a bug in either.

See the CLI quickstart for the full command set.

Next steps

  • Reconnects — the session ID survives an upstream rotation; log it once per session
  • Handle tool calls — tool_call_id correlation in detail
  • Server tools — verify tool names before the session starts

On this page