diff --git a/docs/guides/add-features/add-annotations.md b/docs/guides/add-features/add-annotations.md
new file mode 100644
index 0000000..231d0ef
--- /dev/null
+++ b/docs/guides/add-features/add-annotations.md
@@ -0,0 +1,100 @@
+# Add annotations in assistant messages
+
+ChatKit renders clickable inline citations when assistant text includes `annotations` and rolls every reference into a collapsed **Sources** list beneath each message. You can let the model emit annotations directly or attach sources yourself before streaming the message.
+
+## Use model-emitted citations
+
+When you stream a Responses run through `stream_agent_response`, ChatKit automatically converts any `file_citation`, `container_file_citation`, and `url_citation` annotations returned by the OpenAI API into ChatKit `Annotation` objects and attaches them to streamed message content.
+
+Provide the model with citable evidence via tools to receive citation annotations, most commonly:
+
+- `FileSearchTool` for uploaded documents (emits `file_citation` / `container_file_citation`)
+- `WebSearchTool` for live URLs (emits `url_citation`)
+
+No additional server-side wiring is required beyond calling `stream_agent_response`. If the model emits citation annotations from tool usage, ChatKit will forward them automatically as `Annotation` objects on the corresponding content parts.
+
+
+## Attach sources manually
+
+If you build assistant messages yourself, include annotations on each `AssistantMessageContent` item.
+
+```python
+from datetime import datetime
+from chatkit.types import (
+ Annotation,
+ AssistantMessageContent,
+ AssistantMessageItem,
+ FileSource,
+ ThreadItemDoneEvent,
+ URLSource,
+)
+
+text = "Quarterly revenue grew 12% year over year."
+annotations = [
+ Annotation(
+ source=FileSource(filename="q1_report.pdf", title="Q1 Report"),
+ index=len(text) - 1, # attach near the end of the sentence
+ ),
+ Annotation(
+ source=URLSource(
+ url="https://example.com/press-release",
+ title="Press release",
+ ),
+ index=len(text) - 1,
+ ),
+]
+
+yield ThreadItemDoneEvent(
+ item=AssistantMessageItem(
+ id=self.store.generate_item_id("message", thread, context),
+ thread_id=thread.id,
+ created_at=datetime.now(),
+ content=[AssistantMessageContent(text=text, annotations=annotations)],
+ )
+)
+```
+
+`index` is the character position to place the footnote marker; re-use the same index when multiple citations support the same claim so the footnote numbers stay grouped.
+
+## Annotating with custom entities
+
+Inline annotations are not yet supported for entity sources, but you can still attach `EntitySource` items as annotations so they appear in the Sources list below the message.
+
+```python
+from datetime import datetime
+from chatkit.types import (
+ Annotation,
+ AssistantMessageContent,
+ AssistantMessageItem,
+ EntitySource,
+ ThreadItemDoneEvent,
+)
+
+annotations = [
+ Annotation(
+ source=EntitySource(
+ id="customer_123",
+ title="ACME Corp",
+ description="Enterprise plan · 500 seats",
+ icon="suitcase",
+ data={"href": "https://crm.example.com/customers/123"},
+ )
+ )
+]
+
+yield ThreadItemDoneEvent(
+ item=AssistantMessageItem(
+ id=self.store.generate_item_id("message", thread, context),
+ thread_id=thread.id,
+ created_at=datetime.now(),
+ content=[
+ AssistantMessageContent(
+ text="Here are the ACME account details for reference.",
+ annotations=annotations,
+ )
+ ],
+ )
+)
+```
+
+Provide richer previews and navigation by handling [`entities.onRequestPreview`](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/entitiesoption/#onrequestpreview) and [`entities.onClick`](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/entitiesoption/#onclick) in ChatKit.js, using the `data` payload to pass entity information and deep link into your app.
diff --git a/docs/guides/add-features/allow-mentions.md b/docs/guides/add-features/allow-mentions.md
new file mode 100644
index 0000000..eeb4685
--- /dev/null
+++ b/docs/guides/add-features/allow-mentions.md
@@ -0,0 +1,129 @@
+# Allow @-mentions in user messages
+
+Mentions travel through ChatKit as structured tags so the model can resolve entities instead of guessing from free text. Send `input_tag` parts from the client and translate them into model-readable context on the server.
+
+## Enable as-you-type entity lookup in the composer
+
+To enable entity tagging as @-mentions in the composer, configure [`entities.onTagSearch`](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/entitiesoption/#ontagsearch) as a ChatKit.js option.
+
+It should return a list of [Entity](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/entity/) objects that match the query string.
+
+
+```ts
+const chatkit = useChatKit({
+ // ...
+ entities: {
+ onTagSearch: async (query: string) => {
+ return [
+ {
+ id: "article_123",
+ title: "The Future of AI",
+ group: "Trending",
+ icon: "globe",
+ data: { type: "article" }
+ },
+ {
+ id: "article_124",
+ title: "One weird trick to improve your sleep",
+ group: "Trending",
+ icon: "globe",
+ data: { type: "article" }
+ },
+ ]
+ },
+ },
+})
+```
+
+## Convert tags into model input in your server
+
+Override `ThreadItemConverter.tag_to_message_content` to describe what each tag refers to.
+
+Example converter method that wraps the tagged entity details in custom markup:
+
+```python
+from chatkit.agents import ThreadItemConverter
+from chatkit.types import UserMessageTagContent
+from openai.types.responses import ResponseInputTextParam
+
+class MyThreadItemConverter(ThreadItemConverter):
+ async def tag_to_message_content(
+ self, tag: UserMessageTagContent
+ ) -> ResponseInputTextParam:
+ if tag.type == "article":
+ # Load or unpack the entity the tag refers to
+ summary = await fetch_article_summary(tag.id)
+ return ResponseInputTextParam(
+ type="input_text",
+ text=(
+ "\n"
+ f"ID: {tag.id}\n"
+ f"Title: {tag.text}\n"
+ f"Summary: {summary}\n"
+ ""
+ ),
+ )
+```
+
+
+## Pair mentions with retrieval tool calls
+
+When the referenced content is too large to inline, keep the tag lean (id + short summary) and let the model fetch details via a tool. In your system prompt, tell the assistant to call the retrieval tool when it sees an `ARTICLE_TAG`.
+
+Example tool paired with the converter above:
+
+```python
+from agents import Agent, StopAtTools, RunContextWrapper, function_tool
+from chatkit.agents import AgentContext
+
+@function_tool(description_override="Fetch full article content by id.")
+async def fetch_article(ctx: RunContextWrapper[AgentContext], article_id: str):
+ article = await load_article_content(article_id)
+ return {
+ "title": article.title,
+ "content": article.body,
+ "url": article.url,
+ }
+
+assistant = Agent[AgentContext](
+ ...,
+ tools=[fetch_article],
+)
+```
+
+In `tag_to_message_content`, include the id the tool expects (for example, `tag.id` or `tag.data["article_id"]`). The model can then decide to call `fetch_article` to pull the full text instead of relying solely on the brief summary in the tag.
+
+## Prompt the model about mentions
+
+Add short system guidance to help the assistant understand the input item that adds details about the @-mention.
+
+For example:
+
+```
+- ... is a summary of an article the user referenced.
+- Use it as trusted context when answering questions about that article.
+- Do not restate the summary verbatim; answer the user’s question concisely.
+- Call the `fetch_article` tool with the article id from the tag when more
+ detail is needed or the user asks for specifics not in the summary.
+```
+
+Combined with the converter above, the model receives explicit, disambiguated entity context while users keep a rich mention UI.
+
+
+## Handle clicks and previews
+
+Clicks and hover previews apply to the tagged entities shown in past user messages. Mark an entity as interactive when you return it from `onTagSearch` so the client knows to wire these callbacks:
+
+```ts
+{
+ id: "article_123",
+ title: "The Future of AI",
+ group: "Trending",
+ icon: "globe",
+ interactive: true, // clickable/previewable
+ data: { type: "article" }
+}
+```
+
+- `entities.onClick` fires when a user clicks a tag in the transcript. Handle navigation or open a detail view. See the [onClick option](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/entitiesoption/#onclick).
+- `entities.onRequestPreview` runs when the user hovers or taps a tag that has `interactive: true`. Return a `BasicRoot` widget; you can build one with `WidgetTemplate.build_basic(...)` if you are building the preview widgets server-side. See the [onRequestPreview option](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/entitiesoption/#onrequestpreview).
diff --git a/docs/guides/add-features/disable-new-messages.md b/docs/guides/add-features/disable-new-messages.md
new file mode 100644
index 0000000..e3f66cf
--- /dev/null
+++ b/docs/guides/add-features/disable-new-messages.md
@@ -0,0 +1,50 @@
+# Disable new messages for a thread
+
+There are two ways to stop new user messages: temporarily lock a thread or permanently close it when the conversation is finished.
+
+| State | When to use | Input UI | What the user sees |
+|---------|------------------------------------------------|------------------------------------------------|--------------------|
+| Locked | Temporary pause for moderation or admin action | Composer stays on screen but is disabled; the placeholder shows the lock reason. | The reason for the lock in the disabled composer. |
+| Closed | Final state when the conversation is done | The input UI is replaced with an informational banner. | A static default message or a custom reason, if provided. |
+
+## Update thread status (lock, close, or re-open)
+
+Update `thread.status`—whether moving between active, locked, or closed—and persist it.
+
+```python
+from chatkit.types import ActiveStatus, LockedStatus, ClosedStatus
+
+# lock
+thread.status = LockedStatus(reason="Escalated to support.")
+await store.save_thread(thread, context=context)
+
+# close (final)
+thread.status = ClosedStatus(reason="Resolved.")
+await store.save_thread(thread, context=context)
+
+# re-open
+thread.status = ActiveStatus()
+await store.save_thread(thread, context=context)
+```
+
+If you update the thread status within the `respond` method, ChatKit will emit a `ThreadUpdatedEvent` so connected clients update immediately.
+
+You can also update the thread status from a custom client-facing endpoint that updates the store directly (outside of the ChatKit server request flow). If the user is currently viewing the thread, have the client call `chatkit.fetchUpdates()` after the status is persisted so the UI picks up the latest thread state.
+
+## Block server-side work when locked or closed
+
+Thread status only affects the composer UI; ChatKitServer does not automatically reject actions, tool calls, or imperative message adds. Your integration should short-circuit handlers when a thread is disabled:
+
+```python
+class MyChatKitServer(...):
+ async def respond(thread, input_user_message, context):
+ if thread.status.type in {"locked", "closed"}:
+ return
+ # normal processing
+
+ async def action(thread, action, sender, context):
+ if thread.status.type in {"locked", "closed"}:
+ return
+ # normal processing
+```
+
diff --git a/docs/guides/add-features/handle-feedback.md b/docs/guides/add-features/handle-feedback.md
new file mode 100644
index 0000000..6db33fe
--- /dev/null
+++ b/docs/guides/add-features/handle-feedback.md
@@ -0,0 +1,41 @@
+# Handle feedback
+
+## Enable feedback actions on the client
+
+Collect thumbs up/down feedback so you can flag broken answers, retrain on good ones, or alert humans. Enable the message actions in the client by setting [`threadItemActions.feedback`](https://openai.github.io/chatkit-js/api/openai/chatkit/type-aliases/threaditemactionsoption/); ChatKit.js renders the controls and sends an `items.feedback` request when a user clicks them.
+
+```tsx
+const chatkit = useChatKit({
+ // ...
+ threadItemActions: {
+ feedback: true,
+ },
+})
+```
+
+## Implement `add_feedback` on your server
+
+Override the `add_feedback` method on your server to persist the signal anywhere you like.
+
+```python
+from chatkit.server import ChatKitServer
+from chatkit.types import FeedbackKind
+
+class MyChatKitServer(ChatKitServer[RequestContext]):
+ async def add_feedback(
+ self,
+ thread_id: str,
+ item_ids: list[str],
+ feedback: FeedbackKind,
+ context: RequestContext,
+ ) -> None:
+ # Example: write to your analytics/QA store
+ await record_feedback(
+ thread_id=thread_id,
+ item_ids=item_ids,
+ sentiment=feedback,
+ user_id=context.user_id,
+ )
+```
+
+`item_ids` can include assistant messages, tool calls, or widgets. If you need to ignore certain items (for example, hidden system prompts), filter them here before recording.
diff --git a/docs/guides/add-features/show-progress-for-long-running-tools.md b/docs/guides/add-features/show-progress-for-long-running-tools.md
new file mode 100644
index 0000000..5d8d3ea
--- /dev/null
+++ b/docs/guides/add-features/show-progress-for-long-running-tools.md
@@ -0,0 +1,104 @@
+# Show progress for long-running tools
+
+Long-running tools can feel stalled without feedback. Use progress updates for lightweight status pings and workflows for structured, persisted task checklists.
+
+| | Progress updates (`ProgressUpdateEvent`) | Workflow items (`Workflow`, `WorkflowTask*`) |
+|---------------------|------------------------------------------|----------------------------------------------|
+| Purpose | Quick, ephemeral status text | Structured list of tasks with statuses |
+| Persistence | Not saved to the thread | Persisted as thread items |
+| UI | Inline, transient shimmer text | Collapsible checklist widget |
+| When new content streams | Automatically cleared and replaced by streamed content | Remains visible above the streamed content |
+| Best for | Reporting current phase ("Indexing…") | Multi-step plans users may revisit later |
+| How to emit | `ctx.context.stream(ProgressUpdateEvent(...))` | `start_workflow`, `add_workflow_task`, `update_workflow_task`, `end_workflow` |
+
+## Progress updates
+
+Emit `ProgressUpdateEvent` when you need lightweight, real-time status. They stream immediately to the client and disappear after the turn—they are not stored in the thread.
+
+### From tools
+
+Inside a tool, use `AgentContext.stream` to enqueue progress events. They are delivered to the client immediately and are not persisted as thread items.
+
+```python
+from agents import RunContextWrapper, function_tool
+from chatkit.agents import AgentContext
+from chatkit.types import ProgressUpdateEvent
+
+@function_tool()
+async def ingest_files(ctx: RunContextWrapper[AgentContext], paths: list[str]):
+ await ctx.context.stream(ProgressUpdateEvent(icon="upload", text="Uploading..."))
+ await upload(paths)
+
+ await ctx.context.stream(
+ ProgressUpdateEvent(icon="search", text="Indexing and chunking...")
+ )
+ await index_files(paths)
+
+ await ctx.context.stream(ProgressUpdateEvent(icon="check", text="Done"))
+```
+
+`stream_agent_response` will forward these events for you alongside any assistant text or tool call updates.
+
+### From custom pipelines
+
+If you are not using the Agents SDK, yield `ProgressUpdateEvent` directly from the `respond` or `action` methods while your backend works:
+
+```python
+async def respond(...):
+ yield ProgressUpdateEvent(icon="search", text="Searching tickets...")
+ results = await search_tickets()
+
+ yield ProgressUpdateEvent(icon="code", text="Generating summary...")
+ yield from await stream_summary(results)
+```
+
+Use short, action-oriented messages and throttle updates to meaningful stages instead of every percent to avoid noisy streams.
+
+## Workflow items
+
+Use workflows when you want a persisted, user-visible checklist of tasks. They render as a widget in the transcript and survive after the turn. Combine with progress updates if you need both a checklist and lightweight status text.
+
+Workflows support multiple task variants (custom, search, thought, file, image); see [`Task`](../../../api/chatkit/types/#chatkit.types.Task). Summaries shown when closing a workflow use [`WorkflowSummary`](../../../api/chatkit/types/#chatkit.types.WorkflowSummary) (for example, `CustomSummary` in the snippet below).
+
+Example streaming workflow updates using `AgentContext` helpers:
+
+```python
+from agents import RunContextWrapper, function_tool
+from chatkit.agents import AgentContext
+from chatkit.types import CustomSummary, CustomTask, Workflow
+
+@function_tool()
+async def long_running_tool_with_steps(ctx: RunContextWrapper[AgentContext]):
+ # Create an empty workflow container
+ await ctx.context.start_workflow(Workflow(type="custom", tasks=[]))
+
+ # Add and update the first task
+ discovery = CustomTask(title="Search data sources", status_indicator="loading")
+ await ctx.context.add_workflow_task(discovery)
+
+ # Run the first task
+ await search_my_data_sources()
+
+ await ctx.context.update_workflow_task(
+ discovery.model_copy(update={"status_indicator": "complete"}), task_index=0
+ )
+
+ # Add a follow-up task
+ summary = CustomTask(title="Summarize findings", status_indicator="loading")
+ await ctx.context.add_workflow_task(summary)
+
+ # Run the second task
+ await summarize_my_findings()
+
+ await ctx.context.update_workflow_task(
+ summary.model_copy(update={"status_indicator": "complete"}), task_index=1
+ )
+
+ # Close the workflow and collapse it in the UI
+ await ctx.context.end_workflow(
+ summary=CustomSummary(title="Analysis complete"),
+ expanded=False,
+ )
+```
+
+Workflows are saved as thread items by `stream_agent_response` when you yield the associated events; they show up for all participants and remain visible in history.
diff --git a/docs/guides/stream-thread-events.md b/docs/guides/stream-thread-events.md
index 6655418..ce7ab9c 100644
--- a/docs/guides/stream-thread-events.md
+++ b/docs/guides/stream-thread-events.md
@@ -34,43 +34,6 @@ async def long_running_tool(ctx: RunContextWrapper[AgentContext]):
# Tool logic omitted for brevity
```
-Example streaming workflow updates using `AgentContext` helpers:
-
-```python
-@function_tool()
-async def long_running_tool_with_steps(ctx: RunContextWrapper[AgentContext]):
- # Create an empty workflow container
- await ctx.context.start_workflow(Workflow(type="custom", tasks=[]))
-
- # Add and update the first task
- discovery = CustomTask(title="Search data sources", status_indicator="loading")
- await ctx.context.add_workflow_task(discovery)
-
- # Run the first task
- await search_my_data_sources()
-
- await ctx.context.update_workflow_task(
- discovery.model_copy(update={"status_indicator": "complete"}), task_index=0
- )
-
- # Add a follow-up task
- summary = CustomTask(title="Summarize findings", status_indicator="loading")
- await ctx.context.add_workflow_task(summary)
-
- # Run the second task
- await summarize_my_findings()
-
- await ctx.context.update_workflow_task(
- summary.model_copy(update={"status_indicator": "complete"}), task_index=1
- )
-
- # Close the workflow and collapse it in the UI
- await ctx.context.end_workflow(
- summary=CustomSummary(title="Analysis complete"),
- expanded=False,
- )
-```
-
### Handle guardrail triggers
Guardrail tripwires raise `InputGuardrailTripwireTriggered` or `OutputGuardrailTripwireTriggered` once partial assistant output has been rolled back. Catch them around `stream_agent_response` and optionally send a user-facing event so the client knows why the turn stopped.
@@ -120,7 +83,11 @@ class MyChatKitServer(ChatKitServer[MyRequestContext]):
When you stream thread events manually, remember that tools cannot `yield` events. If you skip `stream_agent_response`, you must merge any tool-emitted events yourself—for example, by reading from `AgentContext._events` (populated by `ctx.context.stream(...)` or workflow helpers) and interleaving them with your own `respond` events.
-## Thread lifecycle events
+## Event types at a glance
+
+Use these when emitting events directly (or alongside `stream_agent_response`). Thread lifecycle events become part of conversation history; the others are ephemeral runtime signals that shape client behavior but are not persisted.
+
+### Thread lifecycle events
Thread item events drive the conversation state. ChatKitServer processes these events to persist conversation state before streaming them back to the client.
@@ -132,7 +99,7 @@ Thread item events drive the conversation state. ChatKitServer processes these e
Note: `ThreadItemAddedEvent` does not persist the item. `ChatKitServer` saves on `ThreadItemDoneEvent`/`ThreadItemReplacedEvent`, tracks pending items in between, and handles store writes for all `ThreadItem*Event`s.
-## Errors
+### Errors
Stream an `ErrorEvent` for user-facing errors.
@@ -148,11 +115,19 @@ async def respond(...) -> AsyncIterator[ThreadStreamEvent]:
# Rest of your respond method
```
-## Client effects
+### Progress updates
+
+Stream `ProgressUpdateEvent` to show the user transient status while work is in flight.
+
+See [Show progress for long-running tools](add-features/show-progress-for-long-running-tools.md) for more info.
+
+### Client effects
Use `ClientEffectEvent` to trigger fire-and-forget behavior on the client such as opening a dialog or pushing updates.
-## Stream options
+See [Send client effects](add-features/send-client-effects.md) for more info.
+
+### Stream options
`StreamOptionsEvent` configures runtime stream behavior (for example, allowing user cancellation). `ChatKitServer` emits one at the start of every stream using `get_stream_options`; override that method to change defaults such as `allow_cancel`.
@@ -164,6 +139,11 @@ Add features:
* [Accept attachments](add-features/accept-attachments.md)
* [Make client tool calls](add-features/make-client-tool-calls.md)
* [Send client effects](add-features/send-client-effects.md)
+* [Show progress for long-running tools](add-features/show-progress-for-long-running-tools.md)
* [Stream widgets](add-features/stream-widgets.md)
* [Handle widget actionss](add-features/handle-widget-actions.md)
-* [Create custom forms](add-features/create-custom-forms.md)
\ No newline at end of file
+* [Create custom forms](add-features/create-custom-forms.md)
+* [Handle feedback](add-features/handle-feedback.md)
+* [Allow @-mentions in user messages](add-features/allow-mentions.md)
+* [Add annotations in assistant messages](add-features/add-annotations.md)
+* [Disable new messages for a thread](add-features/disable-new-messages.md)
diff --git a/mkdocs.yml b/mkdocs.yml
index c6985af..cd15fe7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -50,14 +50,14 @@ nav:
- Accept attachments: guides/add-features/accept-attachments.md
- Make client tool calls: guides/add-features/make-client-tool-calls.md
- Send client effects: guides/add-features/send-client-effects.md
+ - Show progress for long-running tools: guides/add-features/show-progress-for-long-running-tools.md
- Stream widgets: guides/add-features/stream-widgets.md
- Handle widget actions: guides/add-features/handle-widget-actions.md
- Create custom forms: guides/add-features/create-custom-forms.md
- # - Allow @-mentions in user messages
- # - Add citations and sources in assistant messages
- # - Show progress for long-running tools
- # - Lock threads
- # - Handle feedback
+ - Handle feedback: guides/add-features/handle-feedback.md
+ - Allow @-mentions in user messages: guides/add-features/allow-mentions.md
+ - Add annotations in assistant messages: guides/add-features/add-annotations.md
+ - Disable new messages for a thread: guides/add-features/disable-new-messages.md
- API Reference:
- Overview: api/chatkit/index.md
- Modules: