From 8bb983f11557c5b3d339fb15fa9fd66ae923bba9 Mon Sep 17 00:00:00 2001 From: David Weedon Date: Fri, 20 Feb 2026 14:18:10 -0600 Subject: [PATCH 1/4] add sync action handling --- chatkit/actions.py | 3 + chatkit/server.py | 44 ++++++++++++++ chatkit/types.py | 13 +++++ tests/test_chatkit_server.py | 107 +++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+) diff --git a/chatkit/actions.py b/chatkit/actions.py index e00b3ad..0848445 100644 --- a/chatkit/actions.py +++ b/chatkit/actions.py @@ -16,6 +16,7 @@ class ActionConfig(BaseModel): payload: Any = None handler: Handler = DEFAULT_HANDLER loadingBehavior: LoadingBehavior = DEFAULT_LOADING_BEHAVIOR + streaming: bool = True TType = TypeVar("TType", bound=str) @@ -32,6 +33,7 @@ def create( payload: TPayload, handler: Handler = DEFAULT_HANDLER, loading_behavior: LoadingBehavior = DEFAULT_LOADING_BEHAVIOR, + streaming: bool = True, ) -> ActionConfig: actionType: Any = None anno = cls.model_fields["type"].annotation @@ -50,4 +52,5 @@ def create( payload=payload, handler=handler, loadingBehavior=loading_behavior, + streaming=streaming, ) diff --git a/chatkit/server.py b/chatkit/server.py index a94764c..4f08be9 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -52,6 +52,7 @@ StreamingReq, StreamOptions, StreamOptionsEvent, + SyncCustomActionResponse, Thread, ThreadCreatedEvent, ThreadItem, @@ -69,6 +70,7 @@ ThreadsGetByIdReq, ThreadsListReq, ThreadsRetryAfterItemReq, + ThreadsSyncCustomActionReq, ThreadStreamEvent, ThreadsUpdateReq, ThreadUpdatedEvent, @@ -355,6 +357,19 @@ def action( "See https://github.com/openai/chatkit-python/blob/main/docs/widgets.md#widget-actions" ) + def sync_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: TContext, + ) -> SyncCustomActionResponse: + """Handle a widget or client-dispatched action and return a SyncCustomActionResponse.""" + raise NotImplementedError( + "The sync_action() method must be overridden to react to sync actions. " + "See https://github.com/openai/chatkit-python/blob/main/docs/widgets.md#widget-actions" + ) + def get_stream_options( self, thread: ThreadMetadata, context: TContext ) -> StreamOptions: @@ -504,6 +519,8 @@ async def _process_non_streaming( request.params.thread_id, context=context ) return b"{}" + case ThreadsSyncCustomActionReq(): + return await self._process_sync_custom_action(request, context) case _: assert_never(request) @@ -671,6 +688,33 @@ async def _process_streaming_impl( case _: assert_never(request) + async def _process_sync_custom_action( + self, request: ThreadsSyncCustomActionReq, context: TContext + ) -> bytes: + thread_metadata = await self.store.load_thread( + request.params.thread_id, context=context + ) + + item: ThreadItem | None = None + if request.params.item_id: + item = await self.store.load_item( + request.params.thread_id, + request.params.item_id, + context=context, + ) + + if item and not isinstance(item, WidgetItem): + raise ValueError("threads.sync_custom_action requires a widget sender item") + + return self._serialize( + self.sync_action( + thread_metadata, + request.params.action, + item, + context, + ) + ) + async def _cleanup_pending_client_tool_call( self, thread: ThreadMetadata, context: TContext ) -> None: diff --git a/chatkit/types.py b/chatkit/types.py index 2deef02..da099b9 100644 --- a/chatkit/types.py +++ b/chatkit/types.py @@ -115,6 +115,13 @@ class ThreadsCustomActionReq(BaseReq): params: ThreadCustomActionParams +class ThreadsSyncCustomActionReq(BaseReq): + """Request to execute a custom action and return a single item update.""" + + type: Literal["threads.sync_custom_action"] = "threads.sync_custom_action" + params: ThreadCustomActionParams + + class ThreadCustomActionParams(BaseModel): """Parameters describing the custom action to execute.""" @@ -281,6 +288,7 @@ class ThreadDeleteParams(BaseModel): | ThreadsUpdateReq | ThreadsDeleteReq | InputTranscribeReq + | ThreadsSyncCustomActionReq ) """Union of request types that yield immediate responses.""" @@ -539,6 +547,11 @@ class GeneratedImageUpdated(BaseModel): """Union of possible updates applied to thread items.""" +class SyncCustomActionResponse(BaseModel): + """Single thread item update returned by a sync custom action.""" + updated_item: ThreadItem | None = None + + ### THREAD TYPES diff --git a/tests/test_chatkit_server.py b/tests/test_chatkit_server.py index 82b16d3..755928c 100644 --- a/tests/test_chatkit_server.py +++ b/tests/test_chatkit_server.py @@ -49,6 +49,7 @@ LockedStatus, Page, ProgressUpdateEvent, + SyncCustomActionResponse, Thread, ThreadAddClientToolOutputParams, ThreadAddUserMessageParams, @@ -74,6 +75,7 @@ ThreadsGetByIdReq, ThreadsListReq, ThreadsRetryAfterItemReq, + ThreadsSyncCustomActionReq, ThreadStreamEvent, ThreadsUpdateReq, ThreadUpdatedEvent, @@ -163,6 +165,11 @@ def make_server( AsyncIterator[ThreadStreamEvent], ] | None = None, + sync_action_callback: Callable[ + [ThreadMetadata, Action[str, Any], WidgetItem | None, Any], + SyncCustomActionResponse, + ] + | None = None, file_store: AttachmentStore | None = None, transcribe_callback: Callable[[AudioInput, Any], TranscriptionResult] | None = None, ): @@ -187,6 +194,17 @@ def action( raise ValueError("action_callback not wired up") return action_callback(thread, action, sender, context) + def sync_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: Any, + ) -> SyncCustomActionResponse: + if sync_action_callback is None: + raise ValueError("sync_action_callback not wired up") + return sync_action_callback(thread, action, sender, context) + def respond( self, thread: ThreadMetadata, @@ -979,6 +997,95 @@ async def action( assert events[1].update.widget == Card(children=[Text(value="Email sent!")]) +async def test_calls_action_sync(): + sync_actions = [] + + async def responder( + thread: ThreadMetadata, + input: UserMessageItem | None, + context: Any, + ) -> AsyncIterator[ThreadStreamEvent]: + yield ThreadItemDoneEvent( + item=WidgetItem( + id="widget_1", + type="widget", + created_at=datetime.now(), + thread_id=thread.id, + widget=Card( + children=[ + Text( + key="text_1", + value="test", + ) + ], + ), + ), + ) + + def sync_action( + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: Any, + ) -> SyncCustomActionResponse: + sync_actions.append((action, sender)) + assert sender + + return SyncCustomActionResponse( + updated_item=sender.model_copy( + update={ + "widget": Card( + children=[ + Text(value="Email sent!"), + ] + ) + } + ) + ) + + with make_server(responder, sync_action_callback=sync_action) as server: + events = await server.process_streaming( + ThreadsCreateReq( + params=ThreadCreateParams( + input=UserMessageInput( + content=[UserMessageTextContent(text="Show widget")], + attachments=[], + inference_options=InferenceOptions(), + ) + ) + ) + ) + thread = next( + event.thread for event in events if event.type == "thread.created" + ) + widget_item = next( + event.item + for event in events + if isinstance(event, ThreadItemDoneEvent) + and isinstance(event.item, WidgetItem) + ) + + result = await server.process_non_streaming( + ThreadsSyncCustomActionReq( + params=ThreadCustomActionParams( + thread_id=thread.id, + item_id=widget_item.id, + action=Action(type="create_user", payload={"user_id": "123"}), + ) + ) + ) + response = TypeAdapter(SyncCustomActionResponse).validate_json(result.json) + + assert sync_actions + assert sync_actions[0] == ( + Action(type="create_user", payload={"user_id": "123"}), + widget_item, + ) + assert response.updated_item == widget_item.model_copy( + update={"widget": Card(children=[Text(value="Email sent!")])} + ) + + async def test_add_feedback(): called = [] From e5d2729af8cac80bf28e8ca614797970dc90aaf0 Mon Sep 17 00:00:00 2001 From: David Weedon Date: Fri, 20 Feb 2026 14:18:16 -0600 Subject: [PATCH 2/4] Add docs for sync actions --- docs/concepts/actions.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/concepts/actions.md b/docs/concepts/actions.md index c90816a..5f01d98 100644 --- a/docs/concepts/actions.md +++ b/docs/concepts/actions.md @@ -14,9 +14,14 @@ If you leave the handler unset, the action is delivered to `ChatKitServer.action When you set `handler: "client"`, the action flows into the client SDK’s `widgets.onAction` callback so you can do immediate UI work such as opening dialogs, navigating, or running local validation. Client handlers can still forward a follow-up action to the server with `chatkit.sendCustomAction()` after local logic finishes. The server thread stays unchanged unless you explicitly send that follow-up action or a message. +### Sync server-handled actions + +Normally, widget actions are blocked while a thread is streaming a response. This prevents race conditions, but it can be limiting when a widget action is only going to update itself or trigger side effects outside the thread. To get around this limitation, set `streaming: "false"`, which delivers the action to `ChatKitServer.sync_action(thread, action, sender, context)`. Use this handler to run any side effects and update the widget's UI as needed. + ## Client-sent actions using the chatkit.sendCustomAction() command Your client integration can also initiate actions directly with `chatkit.sendCustomAction(action, itemId?)`, optionally namespaced to a specific widget item. The server receives these in `ChatKitServer.action` just like a widget-triggered action and can stream widgets, messages, or client effects in response. This pattern is useful when a flow starts outside a widget—or after a client-handled action—but you still want the server to persist results or involve the model. ## Related guides + - [Build interactive responses with widgets](../guides/build-interactive-responses-with-widgets.md) From 8482acfd116792afedf1e8ab91a1b5681d0996a7 Mon Sep 17 00:00:00 2001 From: David Weedon Date: Fri, 20 Feb 2026 14:19:21 -0600 Subject: [PATCH 3/4] version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80f42e7..fd44002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai-chatkit" -version = "1.6.1" +version = "1.6.2" description = "A ChatKit backend SDK." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 99096ed..e0cb9c1 100644 --- a/uv.lock +++ b/uv.lock @@ -819,7 +819,7 @@ wheels = [ [[package]] name = "openai-chatkit" -version = "1.6.1" +version = "1.6.2" source = { virtual = "." } dependencies = [ { name = "jinja2" }, From d14ce4602e2002ef97ccff685ec14fb27c49c94c Mon Sep 17 00:00:00 2001 From: David Weedon Date: Fri, 20 Feb 2026 14:22:59 -0600 Subject: [PATCH 4/4] async --- chatkit/server.py | 4 ++-- tests/test_chatkit_server.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chatkit/server.py b/chatkit/server.py index 4f08be9..b740a3f 100644 --- a/chatkit/server.py +++ b/chatkit/server.py @@ -357,7 +357,7 @@ def action( "See https://github.com/openai/chatkit-python/blob/main/docs/widgets.md#widget-actions" ) - def sync_action( + async def sync_action( self, thread: ThreadMetadata, action: Action[str, Any], @@ -707,7 +707,7 @@ async def _process_sync_custom_action( raise ValueError("threads.sync_custom_action requires a widget sender item") return self._serialize( - self.sync_action( + await self.sync_action( thread_metadata, request.params.action, item, diff --git a/tests/test_chatkit_server.py b/tests/test_chatkit_server.py index 755928c..054eba4 100644 --- a/tests/test_chatkit_server.py +++ b/tests/test_chatkit_server.py @@ -194,7 +194,7 @@ def action( raise ValueError("action_callback not wired up") return action_callback(thread, action, sender, context) - def sync_action( + async def sync_action( self, thread: ThreadMetadata, action: Action[str, Any],