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 running.
  3. tool-result — the handler finished; ok and summary are available.

Correlate the three events using tool_call_id — a stable per-invocation 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.

A client tool's reply is capped, and an over-cap result reaches the model shortened rather than whole — so a tool-result you are watching can be ok while the model saw a partial answer. See Keep the reply small for the cap and the marker that says so.

Subscribe to the tool lifecycle

All three SDKs deliver the same three events; only the subscription idiom differs.

Two subscription styles are available: session-level events and the React hook.

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({ 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 frames tool-call / tool-dispatch-started / tool-result.

React: useToolCalls()

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

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

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.

Build a timeline with timestamps

If you need timestamps, subscribe on the session directly rather than through the provider's snapshot:

Model an entry per invocation, keyed by toolCallId, with one timestamp per lifecycle event:

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

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

The hook appends an entry when tool_call fires, then patches it in place as tool_dispatch_started and tool_result land:

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

  useEffect(() => {
    if (session === null) return;
    const unsubCall = session.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 = session.on('tool_dispatch_started', (e) => {
      setEntries((prev) =>
        prev.map((entry) =>
          entry.toolCallId === e.toolCallId
            ? { ...entry, dispatchedAt: Date.now() }
            : entry,
        ),
      );
    });

    const unsubResult = session.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();
    };
  }, [session]);

  return entries;
}

The session is an async iterator of typed events — match on ToolCallEvent, ToolDispatchStartedEvent, and ToolResultEvent:

from cosmo_ai import (
    RealtimeClient,
    ToolCallEvent,
    ToolDispatchStartedEvent,
    ToolResultEvent,
)

async with RealtimeClient(api_key="cosmo_...") as client:
    async with client.agent().start() as session:
        async for event in session:
            match event:
                case ToolCallEvent():
                    print(f"[tool-call] name={event.name} id={event.tool_call_id}")
                case ToolDispatchStartedEvent():
                    print(f"[dispatch-started] name={event.name} id={event.tool_call_id}")
                case ToolResultEvent():
                    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 ToolCallEvent():
            in_flight[event.tool_call_id] = event.name
            print(f"started: {event.name}")
        case ToolResultEvent():
            name = in_flight.pop(event.tool_call_id, "unknown")
            status = "ok" if event.ok else "error"
            print(f"finished: {name} [{status}] {event.summary or ''}")

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

import CosmoRealtime

let client = RealtimeClient(apiKey: apiKey)
let agent = try client.agent(tools: [.webSearchTool()])
let session = try await agent.start()

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 through the tool's local handler.

Inspect event fields

Each tool event carries the correlation id plus fields specific to its stage.

tool-call

The model decided to call a tool. TypeScript's event map spells this tool_call. Its fields:

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

tool-dispatch-started

The server-side handler began running. TypeScript's event map spells this tool_dispatch_started. Its fields:

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

tool-result

The handler returned. TypeScript's event map spells this tool_result. Its fields:

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 tools — control which server tools the agent can call
  • Debugging — correlate tool calls with session and tool-call IDs in logs

On this page