Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chatkit/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -50,4 +52,5 @@ def create(
payload=payload,
handler=handler,
loadingBehavior=loading_behavior,
streaming=streaming,
)
44 changes: 44 additions & 0 deletions chatkit/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
StreamingReq,
StreamOptions,
StreamOptionsEvent,
SyncCustomActionResponse,
Thread,
ThreadCreatedEvent,
ThreadItem,
Expand All @@ -69,6 +70,7 @@
ThreadsGetByIdReq,
ThreadsListReq,
ThreadsRetryAfterItemReq,
ThreadsSyncCustomActionReq,
ThreadStreamEvent,
ThreadsUpdateReq,
ThreadUpdatedEvent,
Expand Down Expand Up @@ -355,6 +357,19 @@ def action(
"See https://github.com/openai/chatkit-python/blob/main/docs/widgets.md#widget-actions"
)

async 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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
await self.sync_action(
thread_metadata,
request.params.action,
item,
context,
)
)

async def _cleanup_pending_client_tool_call(
self, thread: ThreadMetadata, context: TContext
) -> None:
Expand Down
13 changes: 13 additions & 0 deletions chatkit/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -281,6 +288,7 @@ class ThreadDeleteParams(BaseModel):
| ThreadsUpdateReq
| ThreadsDeleteReq
| InputTranscribeReq
| ThreadsSyncCustomActionReq
)
"""Union of request types that yield immediate responses."""

Expand Down Expand Up @@ -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


Expand Down
5 changes: 5 additions & 0 deletions docs/concepts/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
107 changes: 107 additions & 0 deletions tests/test_chatkit_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
LockedStatus,
Page,
ProgressUpdateEvent,
SyncCustomActionResponse,
Thread,
ThreadAddClientToolOutputParams,
ThreadAddUserMessageParams,
Expand All @@ -74,6 +75,7 @@
ThreadsGetByIdReq,
ThreadsListReq,
ThreadsRetryAfterItemReq,
ThreadsSyncCustomActionReq,
ThreadStreamEvent,
ThreadsUpdateReq,
ThreadUpdatedEvent,
Expand Down Expand Up @@ -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,
):
Expand All @@ -187,6 +194,17 @@ def action(
raise ValueError("action_callback not wired up")
return action_callback(thread, action, sender, context)

async 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,
Expand Down Expand Up @@ -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 = []

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.