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:
tool-call— the model decided to call a tool.tool-dispatch-started— the server-side handler began running.tool-result— the handler finished;okandsummaryare 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:
| Field | Type | Description |
|---|---|---|
toolCallId / tool_call_id | string | Stable per-invocation ID — correlate all three events with this. |
name | string | Tool 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:
| Field | Type | Description |
|---|---|---|
toolCallId / tool_call_id | string | Same ID from the matching tool-call. |
name | string | Tool name. |
tool-result
The handler returned. TypeScript's event map spells this tool_result. Its fields:
| Field | Type | Description |
|---|---|---|
toolCallId / tool_call_id | string | Same ID from the matching tool-call. |
ok | boolean | true if the tool completed successfully. |
summary | string | null | Short 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
Swift platform behavior
What the SDK does to the audio device on macOS and iOS, and the platform work your app still owns — permissions, prewarming, volume, and route changes.
Add production guardrails
Scrub sensitive arguments, gate tool calls with a classifier, audit every outcome, and verify the call is complete before it ends.