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 executing.tool-result— the handler finished;okandsummaryare 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
| Field | Type | Description |
|---|---|---|
toolCallId / tool_call_id | string | Stable per-invocation ID — correlate all three events with this. |
name | string | Tool name, e.g. "web_search". |
tool_dispatch_started / tool-dispatch-started
| Field | Type | Description |
|---|---|---|
toolCallId / tool_call_id | string | Same ID from the matching tool-call. |
name | string | Tool name. |
tool_result / tool-result
| 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-side tools — control which server tools the agent can call
- Debugging — correlate tool calls with session and message IDs in logs
Packaging a macOS app
Embedding LiveKit's binary frameworks, the rpath, signing order, entitlements, and the microphone purpose string — what `swift build` alone does not do.
Envelope chunking
How the SDK auto-chunks messages that exceed the 15 KiB data-channel limit, when this becomes visible to callers, and how each SDK handles reassembly.