Skip to content

Commit 09d8cac

Browse files
authored
Convert all previous examples for client tool call to use client effect; add a real client tool call example where the output matters (#39)
* location_select_mode is now a client effect instead of a client tool call * Use client effects for fire-and-forget data pushes from server to client * update metro map to have a real example of a client tool call * updated readmes
1 parent 8b73741 commit 09d8cac

File tree

16 files changed

+325
-172
lines changed

16 files changed

+325
-172
lines changed

README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,18 @@ You can run the following examples:
3636
- `show_line_selector` presents the user a multiple-choice question using a widget.
3737
- Route-planning replies attach entity sources for the stations in the suggested path as annotations.
3838

39-
### Client tool calls that mutate UI state
39+
### Client tool calls that mutate or fetch UI state
40+
41+
- **Metro Map**:
42+
- Client tool `get_selected_stations` pulls the currently selected nodes from the canvas so the agent can use client-side state in its response ([ChatKitPanel.tsx](examples/metro-map/frontend/src/components/ChatKitPanel.tsx), [metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).
43+
44+
### Fire-and-forget client effects
4045

4146
- **Cat Lounge**:
42-
- Client tool `update_cat_status` is invoked by server tools `feed_cat`, `play_with_cat`, `clean_cat`, and `speak_as_cat` to sync UI state.
43-
- When invoked, it is handled client-side with the `handleClientToolCall` callback in [ChatKitPanel.tsx](examples/cat-lounge/frontend/src/components/ChatKitPanel.tsx).
44-
- **News Guide**:
45-
- The `open_article` widget action triggers client-side navigation to the selected story and forwards the action back to the server via `sendCustomAction` to follow up with context-aware prompts ([ChatKitPanel.tsx](examples/news-guide/frontend/src/components/ChatKitPanel.tsx)).
47+
- Client effects `update_cat_status` and `cat_say` are invoked by server tools to sync UI state and surface speech bubbles; handled via `onEffect` in [ChatKitPanel.tsx](examples/cat-lounge/frontend/src/components/ChatKitPanel.tsx).
4648
- **Metro Map**:
47-
- Client tools `add_station` (sent after the server adds a stop) and `location_select_mode` (sent after a line is chosen) update the metro map canvas ([ChatKitPanel.tsx](examples/metro-map/frontend/src/components/ChatKitPanel.tsx)).
48-
- Inference is deliberately skipped after the `location_select_mode` client tool call output is sent to the server; the `respond` method on the server early returns when the last item in the thread is the `location_select_mode` client tool call ([server.py](examples/news-guide/backend/app/server.py)).
49-
- The `location_select_mode` client tool call is streamed within the server action handler ([server.py](examples/news-guide/backend/app/server.py)).
49+
- Client effect `location_select_mode` is streamed within the server action handler ([server.py](examples/metro-map/backend/app/server.py)) after a line is chosen and updates the metro map canvas ([ChatKitPanel.tsx](examples/metro-map/frontend/src/components/ChatKitPanel.tsx)).
50+
- Client effect `add_station` is streamed by the agent after map updates to immediately sync the canvas and focus the newly created stop ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py), [ChatKitPanel.tsx](examples/metro-map/frontend/src/components/ChatKitPanel.tsx)).
5051

5152
### Page-aware model responses
5253

@@ -60,7 +61,12 @@ You can run the following examples:
6061
- Retrieval tools stream `ProgressUpdateEvent` messages while searching tags, authors, keywords, exact text, or loading the current page so the UI surfaces “Searching…”/“Loading…” states ([news_agent.py](examples/news-guide/backend/app/agents/news_agent.py)).
6162
- The event finder emits progress as it scans dates, days of week, or keywords to keep users informed during longer lookups ([event_finder_agent.py](examples/news-guide/backend/app/agents/event_finder_agent.py)).
6263
- **Metro Map**:
63-
- The metro agent emits a quick sync update when it loads the line data via `get_map` ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).
64+
- The metro agent emits a progress update while retrieving map information in `get_map`; it also emits a progress update while waiting for a client tool call to complete in `get_selected_stations` ([metro_map_agent.py](examples/metro-map/backend/app/agents/metro_map_agent.py)).
65+
66+
### Response lifecycle UI state
67+
68+
- **Metro Map**:
69+
- The client locks map interaction at response start and unlocks when the stream ends so canvas state doesn’t drift during agent updates by adding `onResponseStart` and `onResponseEnd` handlers ([ChatKitPanel.tsx](examples/metro-map/frontend/src/components/ChatKitPanel.tsx)).
6470

6571
### Widgets without actions
6672

@@ -86,7 +92,7 @@ You can run the following examples:
8692
- **News Guide**:
8793
- The `view_event_details` action is processed server-side to update the timeline widget with expanded descriptions without a round trip to the model ([server.py](examples/news-guide/backend/app/server.py)).
8894
- **Metro Map**:
89-
- The `line.select` action is handled server-side to stream an updated widget, add a `<LINE_SELECTED>` hidden context item to thread, stream an assistant message to ask the user whether to add the station at the line’s start or end, and trigger the `location_select_mode` client tool call for the UI to sync ([server.py](examples/metro-map/backend/app/server.py)).
95+
- The `line.select` action is handled server-side to stream an updated widget, add a `<LINE_SELECTED>` hidden context item to thread, stream an assistant message to ask the user whether to add the station at the line’s start or end, and trigger the `location_select_mode` client effect for the UI to sync ([server.py](examples/metro-map/backend/app/server.py)).
9096

9197
### Annotations
9298

examples/cat-lounge/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ Virtual cat caretaker demo built with ChatKit (FastAPI backend + Vite/React fron
2121
- Server tools to read and mutate per-thread cat state: `get_cat_status`, `feed_cat`, `play_with_cat`, `clean_cat`, `set_cat_name`, `speak_as_cat`.
2222
- Name suggestion workflow with a selectable widget and client-handled actions (`cats.select_name`, `cats.more_names`) plus server reconciliation for chosen names.
2323
- Profile card widget (`show_cat_profile`) streamed from the server with presentation-only content.
24-
- Client tool call `update_cat_status` keeps the UI stats in sync after each server tool invocation.
24+
- One-way client effects (`update_cat_status`, `cat_say`) are streamed from the server to keep the UI stats in sync and surface speech bubbles after each server tool invocation.
2525
- Hidden context tags track recent actions (<FED_CAT>, <PLAYED_WITH_CAT>, <CLEANED_CAT>, <CAT_NAME_SELECTED>) so the agent remembers what already happened.
2626
- Quick actions call `chatkit.sendUserMessage` to send canned requests without typing ([App.tsx](frontend/src/App.tsx)).

examples/cat-lounge/backend/README.md

Lines changed: 0 additions & 23 deletions
This file was deleted.

examples/cat-lounge/backend/app/cat_agent.py

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
from typing import Annotated, Any, Callable
66

77
from agents import Agent, RunContextWrapper, StopAtTools, function_tool
8-
from chatkit.agents import AgentContext, ClientToolCall
8+
from chatkit.agents import AgentContext
99
from chatkit.types import (
1010
AssistantMessageContent,
1111
AssistantMessageItem,
12+
ClientEffectEvent,
1213
HiddenContextItem,
1314
ThreadItemDoneEvent,
1415
)
@@ -94,12 +95,14 @@ async def _sync_status(
9495
state: CatState,
9596
flash: str | None = None,
9697
) -> None:
97-
ctx.context.client_tool_call = ClientToolCall(
98-
name="update_cat_status",
99-
arguments={
100-
"state": state.to_payload(ctx.context.thread.id),
101-
"flash": flash,
102-
},
98+
await ctx.context.stream(
99+
ClientEffectEvent(
100+
name="update_cat_status",
101+
data={
102+
"state": state.to_payload(ctx.context.thread.id),
103+
"flash": flash,
104+
},
105+
)
103106
)
104107

105108

@@ -297,13 +300,22 @@ async def speak_as_cat(
297300
if not message:
298301
raise ValueError("A line is required for the cat to speak.")
299302
state = await _get_state(ctx)
300-
ctx.context.client_tool_call = ClientToolCall(
301-
name="cat_say",
302-
arguments={
303-
"state": state.to_payload(ctx.context.thread.id),
304-
"message": message,
305-
},
303+
await ctx.context.stream(
304+
ClientEffectEvent(
305+
name="cat_say",
306+
data={
307+
"state": state.to_payload(ctx.context.thread.id),
308+
"message": message,
309+
},
310+
)
306311
)
312+
# ctx.context.client_tool_call = ClientToolCall(
313+
# name="cat_say",
314+
# arguments={
315+
# "state": state.to_payload(ctx.context.thread.id),
316+
# "message": message,
317+
# },
318+
# )
307319

308320

309321
@function_tool(
@@ -362,27 +374,21 @@ async def suggest_cat_names(
362374
get_cat_status,
363375
# Produces a simple widget output.
364376
show_cat_profile,
365-
# Invokes a simple client tool call to make the cat speak.
377+
# Invokes a client effect to make the cat speak.
366378
speak_as_cat,
367-
# Mutates state then invokes a client tool call to sync client state.
379+
# Mutates state then invokes a client effect to sync client state.
368380
feed_cat,
369381
play_with_cat,
370382
clean_cat,
371-
# Mutates both cat state and thread state then invokes a client tool call
383+
# Mutates both cat state and thread state then invokes a client effect
372384
# to sync client state.
373385
set_cat_name,
374386
# Outputs interactive widget output with partially agent-generated content.
375387
suggest_cat_names,
376388
],
377-
# Stop inference after client tool calls or tool calls with widget outputs
378-
# to prevent repetition.
389+
# Stop inference after tool calls with widget outputs to prevent repetition.
379390
tool_use_behavior=StopAtTools(
380391
stop_at_tool_names=[
381-
feed_cat.name,
382-
play_with_cat.name,
383-
clean_cat.name,
384-
speak_as_cat.name,
385-
set_cat_name.name,
386392
suggest_cat_names.name,
387393
show_cat_profile.name,
388394
]

examples/cat-lounge/backend/app/server.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
AssistantMessageItem,
1818
Attachment,
1919
HiddenContextItem,
20+
StreamOptions,
2021
ThreadItemDoneEvent,
2122
ThreadItemReplacedEvent,
2223
ThreadMetadata,
@@ -110,6 +111,10 @@ async def respond(
110111
yield event
111112
return
112113

114+
def get_stream_options(self, thread: ThreadMetadata, context: dict[str, Any]) -> StreamOptions:
115+
# Don't allow stream cancellation because most cat-lounge interactions update the cat's state.
116+
return StreamOptions(allow_cancel=False)
117+
113118
async def to_message_content(self, _input: Attachment) -> ResponseInputContentParam:
114119
raise RuntimeError("File attachments are not supported in this demo.")
115120

examples/cat-lounge/frontend/src/components/ChatKitPanel.tsx

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,29 +83,23 @@ export function ChatKitPanel({
8383
[refresh, handleStatusUpdate, activeThread]
8484
);
8585

86-
const handleClientToolCall = useCallback((toolCall: {
86+
const handleClientEffect = useCallback(({name, data}: {
8787
name: string;
88-
params: Record<string, unknown>;
88+
data: Record<string, unknown>;
8989
}) => {
90-
if (toolCall.name === "update_cat_status") {
91-
const data = toolCall.params.state as CatStatePayload | undefined;
92-
if (data) {
93-
handleStatusUpdate(data, toolCall.params.flash as string | undefined);
90+
if (name === "update_cat_status") {
91+
const catState = data.state as CatStatePayload | undefined;
92+
if (catState) {
93+
handleStatusUpdate(catState, data.flash as string | undefined);
9494
}
95-
return { success: true };
9695
}
9796

98-
if (toolCall.name === "cat_say") {
99-
const message = String(toolCall.params.message ?? "");
97+
if (name === "cat_say") {
98+
const message = String(data.message ?? "");
10099
if (message) {
101-
setSpeech({
102-
message,
103-
mood: toolCall.params.mood as string | undefined,
104-
});
100+
setSpeech({message});
105101
}
106-
return { success: true };
107102
}
108-
return { success: false };
109103
}, [])
110104

111105
const chatkit = useChatKit({
@@ -139,7 +133,6 @@ export function ChatKitPanel({
139133
widgets: {
140134
onAction: handleWidgetAction,
141135
},
142-
onClientTool: handleClientToolCall,
143136
onThreadChange: ({ threadId }) => setThreadId(threadId),
144137
onError: ({ error }) => {
145138
// ChatKit handles displaying the error to the user
@@ -148,6 +141,7 @@ export function ChatKitPanel({
148141
onReady: () => {
149142
onChatKitReady?.(chatkit);
150143
},
144+
onEffect: handleClientEffect,
151145
});
152146
chatkitRef.current = chatkit;
153147

examples/metro-map/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ Chat-driven GUI updates for a metro map using a React Flow canvas that lets the
1313
- "Add a new station named Aurora" (line picker widget will appear)
1414
- "Plan a route from Titan Border to Lyra Verge."
1515
- "Tell me about @Cinderia station." (@-mention stations; need to type @ manually, copy paste won't work)
16+
- "Tell me about the stations I've selected." (lasso some stations on the canvas first)
1617

1718
## Features
1819

1920
- Map sync + lookup tools: `get_map`, `list_lines`, `list_stations`, `get_line_route`, `get_station` keep the agent grounded in the latest network data.
21+
- Selection-aware replies: the agent calls the `get_selected_stations` client tool to pull the user’s current canvas selection before continuing a response, handled in `onClientTool` ([ChatKitPanel.tsx](frontend/src/components/ChatKitPanel.tsx), [metro_map_agent.py](backend/app/agents/metro_map_agent.py)).
2022
- Plan-a-route responses attach entity sources for each station in the recommended path so ChatKit can keep the canvas focused on the stops being discussed.
2123
- Station creation flow: `show_line_selector` streams a clickable `line.select` widget, the server stashes `<LINE_SELECTED>`, and `add_station` triggers a widget update and a client tool call to refresh the canvas and focus the new stop.
22-
- Location placement helper: after a line is chosen, a `location_select_mode` client tool call flips the UI into placement mode so users pick start/end of line for insertion.
24+
- Location placement helper: after a line is chosen, a `location_select_mode` client effect flips the UI into placement mode so users pick start/end of line for insertion.
2325
- Progress updates: initial map fetch streams a quick progress event while loading line data.
2426
- Entity tags: station @-mentions in the composer add `<STATION_TAG>` content for the agent and can be clicked to focus the station on the canvas.
2527
- Custom header action: a right-side icon toggles dark/light themes in the ChatKit header.

examples/metro-map/backend/app/agents/metro_map_agent.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Annotation,
1111
AssistantMessageContent,
1212
AssistantMessageItem,
13+
ClientEffectEvent,
1314
EntitySource,
1415
ProgressUpdateEvent,
1516
ThreadItemDoneEvent,
@@ -68,6 +69,8 @@
6869
When this is the latest message, acknowledge the selection.
6970
- <STATION_TAG>...</STATION_TAG> - contains full station details (id, name, description, coordinates, and served lines with ids/colors/orientations).
7071
Use the data inside the tag directly; do not call `get_station` just to resolve a tagged station.
72+
73+
When the user mentions "selected stations" or asks about the current selection, call `get_selected_stations` to fetch the station ids from the client.
7174
"""
7275

7376

@@ -100,6 +103,10 @@ class StationDetailResult(BaseModel):
100103
lines: list[Line]
101104

102105

106+
class SelectedStationsResult(BaseModel):
107+
station_ids: list[str]
108+
109+
103110
@function_tool(
104111
description_override=(
105112
"Show a clickable widget listing metro lines.\n"
@@ -229,12 +236,14 @@ async def add_station(
229236
await ctx.context.stream(ProgressUpdateEvent(text="Adding station..."))
230237
try:
231238
updated_map, new_station = ctx.context.metro.add_station(station_name, line_id, append)
232-
ctx.context.client_tool_call = ClientToolCall(
233-
name="add_station",
234-
arguments={
235-
"stationId": new_station.id,
236-
"map": updated_map.model_dump(mode="json"),
237-
},
239+
await ctx.context.stream(
240+
ClientEffectEvent(
241+
name="add_station",
242+
data={
243+
"stationId": new_station.id,
244+
"map": updated_map.model_dump(mode="json"),
245+
},
246+
)
238247
)
239248
return MapResult(map=updated_map)
240249
except Exception as e:
@@ -256,6 +265,24 @@ async def add_station(
256265
raise
257266

258267

268+
@function_tool(
269+
description_override=(
270+
"Fetch the ids of the currently selected stations from the client UI. No parameters."
271+
)
272+
)
273+
async def get_selected_stations(
274+
ctx: RunContextWrapper[MetroAgentContext],
275+
) -> SelectedStationsResult:
276+
logger.info("[TOOL CALL] get_selected_stations")
277+
# This progress update will persist while waiting for the client tool output to be send back to the server.
278+
await ctx.context.stream(ProgressUpdateEvent(text="Fetching selected stations from the map..."))
279+
ctx.context.client_tool_call = ClientToolCall(
280+
name="get_selected_stations",
281+
arguments={},
282+
)
283+
return SelectedStationsResult(station_ids=[])
284+
285+
259286
metro_map_agent = Agent[MetroAgentContext](
260287
name="metro_map",
261288
instructions=INSTRUCTIONS,
@@ -272,13 +299,15 @@ async def add_station(
272299
show_line_selector,
273300
# Update the metro map
274301
add_station,
302+
# Request client selection
303+
get_selected_stations,
275304
],
276305
# Stop inference after client tool call or widget output
277306
tool_use_behavior=StopAtTools(
278307
stop_at_tool_names=[
279-
add_station.name,
280308
plan_route.name,
281309
show_line_selector.name,
310+
get_selected_stations.name,
282311
]
283312
),
284313
)

0 commit comments

Comments
 (0)