Cosmo Realtime SDK
Guides

Handle tool calls

Observe the tool-call, tool-dispatch-started, and tool-result lifecycle. Render a tool-call timeline in TypeScript, Python, and Swift.

The server emits a three-event lifecycle for every server-executed tool the agent invokes:

  1. tool-call — the model decided to call a tool.
  2. tool-dispatch-started — the server-side handler began executing.
  3. tool-result — the handler finished; ok and summary are available.

Correlate the three events using tool_call_id — a stable per-invocation ID distinct from the per-message id.

Client-executed tools run through your local handler instead; their invocation surfaces as a tool-invocation observability event. See Tools for the full tool model.

TypeScript

Session-level event subscription

RealtimeSession.on() exposes the typed event map — subscribe to tool_call, tool_dispatch_started, and tool_result:

import { RealtimeClient } from 'cosmo-ai';

const client = new RealtimeClient({ baseUrl: 'https://app.askcosmo.ai', token });
const session = await client.agent({ /* config */ }).start();

session.on('tool_call', (event) => {
  console.log(`tool called: ${event.name} (id=${event.toolCallId})`);
});

session.on('tool_dispatch_started', (event) => {
  console.log(`dispatch started: ${event.name} (id=${event.toolCallId})`);
});

session.on('tool_result', (event) => {
  const status = event.ok ? 'ok' : 'error';
  console.log(`tool result: [${status}] ${event.summary ?? ''} (id=${event.toolCallId})`);
});

Each on() call returns an unsubscribe function. The same events are also available on the session's async iterator (for await (const event of session)) as the wire frames tool-call / tool-dispatch-started / tool-result.

React: useToolCalls()

The CosmoRealtimeProvider maintains a toolCalls list in its React snapshot. useToolCalls() returns it as RealtimeToolCallItem[]:

import { useToolCalls, type RealtimeToolCallItem } from 'cosmo-ai';

type StatusColor = { [K in RealtimeToolCallItem['status']]: string };
const STATUS_COLOR: StatusColor = {
  in_flight: '#92400e',
  ok: '#15803d',
  error: '#b91c1c',
};

function ToolTimeline() {
  const calls = useToolCalls();

  if (calls.length === 0) return null;

  return (
    <ol style={{ listStyle: 'none', padding: 0 }}>
      {calls.map((tc) => (
        <li
          key={tc.toolCallId}
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: 8,
            padding: '4px 0',
            borderLeft: '2px solid #e5e7eb',
            paddingLeft: 12,
          }}
        >
          <span style={{ fontWeight: 600 }}>{tc.name}</span>
          <span style={{ color: STATUS_COLOR[tc.status], fontSize: 12 }}>
            [{tc.status}]
          </span>
          {tc.summary && (
            <span style={{ fontSize: 12, color: '#555' }}>{tc.summary}</span>
          )}
        </li>
      ))}
    </ol>
  );
}

status starts as 'in_flight' when tool_call fires and transitions to 'ok' or 'error' when tool_result arrives.

Building a timeline with timestamps

If you need timestamps, subscribe at the client level directly rather than through the provider's snapshot:

import { useEffect, useState } from 'react';
import { useRealtimeClient } from 'cosmo-ai';

type TimelineEntry = {
  toolCallId: string;
  name: string;
  startedAt: number;
  dispatchedAt: number | null;
  finishedAt: number | null;
  ok: boolean | null;
  summary: string | null;
};

function useToolTimeline(): TimelineEntry[] {
  const client = useRealtimeClient();
  const [entries, setEntries] = useState<TimelineEntry[]>([]);

  useEffect(() => {
    const unsubCall = client.on('tool_call', (e) => {
      setEntries((prev) => [
        ...prev,
        {
          toolCallId: e.toolCallId,
          name: e.name,
          startedAt: Date.now(),
          dispatchedAt: null,
          finishedAt: null,
          ok: null,
          summary: null,
        },
      ]);
    });

    const unsubDispatch = client.on('tool_dispatch_started', (e) => {
      setEntries((prev) =>
        prev.map((entry) =>
          entry.toolCallId === e.toolCallId
            ? { ...entry, dispatchedAt: Date.now() }
            : entry,
        ),
      );
    });

    const unsubResult = client.on('tool_result', (e) => {
      setEntries((prev) =>
        prev.map((entry) =>
          entry.toolCallId === e.toolCallId
            ? { ...entry, finishedAt: Date.now(), ok: e.ok, summary: e.summary }
            : entry,
        ),
      );
    });

    return () => {
      unsubCall();
      unsubDispatch();
      unsubResult();
    };
  }, [client]);

  return entries;
}

Python

The session is an async iterator of typed events — match on RealtimeToolCall, RealtimeToolDispatchStarted, and RealtimeToolResult:

from cosmo_ai import (
    CosmoRealtime,
    RealtimeToolCall,
    RealtimeToolDispatchStarted,
    RealtimeToolResult,
)

async with CosmoRealtime(api_key="cosmo_...") as client:
    async with client.agent().start() as session:
        async for event in session:
            match event:
                case RealtimeToolCall():
                    print(f"[tool-call] name={event.name} id={event.tool_call_id}")
                case RealtimeToolDispatchStarted():
                    print(f"[dispatch-started] name={event.name} id={event.tool_call_id}")
                case RealtimeToolResult():
                    status = "ok" if event.ok else "error"
                    print(f"[tool-result] [{status}] {event.summary or ''} id={event.tool_call_id}")

To track in-flight calls keyed by tool_call_id:

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

async for event in session:
    match event:
        case RealtimeToolCall():
            in_flight[event.tool_call_id] = event.name
            print(f"started: {event.name}")
        case RealtimeToolResult():
            name = in_flight.pop(event.tool_call_id, "unknown")
            status = "ok" if event.ok else "error"
            print(f"finished: {name} [{status}] {event.summary or ''}")

Swift

Match the .toolCall, .toolDispatchStarted, and .toolResult cases on the session's event stream:

import CosmoRealtime

let session = try await RealtimeSession.start(
    .init(apiKey: apiKey, baseURL: baseURL),
    config: SessionConfig(tools: [.webSearch])
)

for try await event in session.events {
    switch event {
    case .toolCall(let call):
        print("[tool-call] \(call.name) id=\(call.toolCallId)")
    case .toolDispatchStarted(let started):
        print("[dispatch-started] \(started.name) id=\(started.toolCallId)")
    case .toolResult(let result):
        let status = result.ok ? "ok" : "error"
        print("[tool-result] [\(status)] \(result.summary ?? "") id=\(result.toolCallId)")
    default:
        break
    }
}

Client-tool invocations surface as .toolInvocation — observability only; execution and the reply happen via the tool's local handler.

Event field reference

tool_call / tool-call

FieldTypeDescription
toolCallId / tool_call_idstringStable per-invocation ID — correlate all three events with this.
namestringTool name, e.g. "web_search".

tool_dispatch_started / tool-dispatch-started

FieldTypeDescription
toolCallId / tool_call_idstringSame ID from the matching tool-call.
namestringTool name.

tool_result / tool-result

FieldTypeDescription
toolCallId / tool_call_idstringSame ID from the matching tool-call.
okbooleantrue if the tool completed successfully.
summarystring | nullShort human-readable result line from the server.

Next steps

  • Tools — client tools, server tools, and the full tool model
  • Server-side tools — control which server tools the agent can call
  • Debugging — correlate tool calls with session and message IDs in logs

On this page