diff --git a/python/packages/chatkit/AGENTS.md b/python/packages/chatkit/AGENTS.md
index 7855b4d4caf..d11724b3ad1 100644
--- a/python/packages/chatkit/AGENTS.md
+++ b/python/packages/chatkit/AGENTS.md
@@ -8,6 +8,9 @@ Integration with OpenAI ChatKit (Python) for building chat UIs.
- **`stream_agent_response()`** - Stream agent responses to ChatKit
- **`simple_to_agent_input()`** - Convert simple input to agent input format
+`ThreadItemConverter.structured_input_to_input()` is an async override hook returning
+`Message | list[Message] | None`; `to_agent_input()` normalizes its output and preserves thread order.
+
## Usage
```python
diff --git a/python/packages/chatkit/README.md b/python/packages/chatkit/README.md
index c4cdb364316..ac154fbec29 100644
--- a/python/packages/chatkit/README.md
+++ b/python/packages/chatkit/README.md
@@ -12,6 +12,10 @@ Specifically, it mirrors the [Agent SDK integration](https://github.com/openai/c
of `ThreadItemConverter` to convert a ChatKit thread to a list of `Message`,
useful for getting started quickly.
+Override the async `ThreadItemConverter.structured_input_to_input()` method to
+customize structured input context, such as formatting or redacting answers.
+Return a `Message`, a list of messages, or `None` to skip the item.
+
## Installation
```bash
diff --git a/python/packages/chatkit/agent_framework_chatkit/_converter.py b/python/packages/chatkit/agent_framework_chatkit/_converter.py
index 88f75598b5b..f6501178f9e 100644
--- a/python/packages/chatkit/agent_framework_chatkit/_converter.py
+++ b/python/packages/chatkit/agent_framework_chatkit/_converter.py
@@ -491,6 +491,39 @@ async def end_of_turn_to_input(self, item: EndOfTurnItem) -> Message | list[Mess
# End-of-turn is only used for UI hints - skip it
return None
+ async def structured_input_to_input(self, item: StructuredInputItem) -> Message | list[Message] | None:
+ """Convert structured-input status and answers to Agent Framework Message(s).
+
+ This method is called internally by `to_agent_input()`. Override this method
+ to customize formatting or redact answers, or return None to skip the item.
+
+ Args:
+ item: The ChatKit structured input item to convert.
+
+ Returns:
+ A Message with user role, a list of messages, or None to skip.
+
+ Note:
+ Use `to_agent_input()` to convert thread items with proper message ordering.
+ """
+ lines: list[str] = []
+ for structured_input in item.inputs:
+ answer = structured_input.answer
+ if answer is None:
+ answer_text = "unanswered"
+ elif answer.skipped:
+ answer_text = "skipped"
+ else:
+ answer_text = ", ".join(answer.values)
+
+ lines.append(f"- {structured_input.question}: {answer_text}")
+
+ text = (
+ "A structured input request was displayed to the user with the following "
+ f"status: {item.status}\n\n" + "\n".join(lines) + "\n"
+ )
+ return Message(role="user", contents=[text])
+
async def _thread_item_to_input_item(
self,
item: ThreadItem,
@@ -537,8 +570,8 @@ async def _thread_item_to_input_item(
# TODO(evmattso): Implement generated image handling in a future PR
return []
case StructuredInputItem():
- # TODO(evmattso): Implement structured input handling in a future PR
- return []
+ out = await self.structured_input_to_input(item) or []
+ return out if isinstance(out, list) else [out]
case _:
# Unknown ThreadItem variant (e.g. types added in newer chatkit versions).
# Skip rather than fail so we remain forward-compatible with chatkit upgrades.
diff --git a/python/packages/chatkit/tests/test_converter.py b/python/packages/chatkit/tests/test_converter.py
index a0fb0244549..b9889f14db2 100644
--- a/python/packages/chatkit/tests/test_converter.py
+++ b/python/packages/chatkit/tests/test_converter.py
@@ -2,6 +2,7 @@
"""Tests for ChatKit to Agent Framework converter utilities."""
+import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import Mock
@@ -641,6 +642,109 @@ async def test_end_of_turn_to_input_returns_none(self, converter):
assert await converter.end_of_turn_to_input(end_item) is None
+ @pytest.mark.parametrize("status", ["pending", "answered", "skipped"])
+ async def test_to_agent_input_with_structured_input(self, converter, status):
+ """Test structured input answers are preserved as model context."""
+ from chatkit.types import (
+ StructuredInputAnswer,
+ StructuredInputFreeform,
+ StructuredInputItem,
+ StructuredInputMultipleChoice,
+ StructuredInputMultipleChoiceOption,
+ )
+
+ input_item = StructuredInputItem(
+ id="structured_1",
+ thread_id="thread_1",
+ created_at=datetime.now(),
+ type="structured_input",
+ status=status,
+ inputs=[
+ StructuredInputMultipleChoice(
+ id="priority",
+ question="What priority should I use?",
+ options=[
+ StructuredInputMultipleChoiceOption(value="High"),
+ StructuredInputMultipleChoiceOption(value="Urgent"),
+ ],
+ multiple=True,
+ answer=StructuredInputAnswer(values=["High", "Urgent"]),
+ ),
+ StructuredInputFreeform(
+ id="notes",
+ question="Any extra context?",
+ answer=StructuredInputAnswer(skipped=True),
+ ),
+ StructuredInputFreeform(
+ id="owner",
+ question="Who owns this?",
+ ),
+ ],
+ )
+
+ result = await converter.to_agent_input(input_item)
+
+ assert len(result) == 1
+ assert result[0].role == "user"
+ assert result[0].text == (
+ f"A structured input request was displayed to the user with the following status: {status}\n"
+ "\n"
+ "- What priority should I use?: High, Urgent\n"
+ "- Any extra context?: skipped\n"
+ "- Who owns this?: unanswered\n"
+ ""
+ )
+
+ @pytest.mark.parametrize(
+ ("converted", "expected_texts"),
+ [
+ (None, []),
+ ([], []),
+ (Message(role="user", contents=["redacted"]), ["redacted"]),
+ ([Message("user", ["first"]), Message("user", ["second"])], ["first", "second"]),
+ ],
+ ids=["skip", "empty", "single", "multiple"],
+ )
+ async def test_to_agent_input_uses_structured_input_override(
+ self, converted: Message | list[Message] | None, expected_texts: list[str]
+ ) -> None:
+ """Test async overrides can skip or expand an item without changing message order."""
+ from chatkit.types import HiddenContextItem, StructuredInputItem
+
+ calls: list[StructuredInputItem] = []
+
+ class CustomConverter(ThreadItemConverter):
+ async def structured_input_to_input(self, item: StructuredInputItem) -> Message | list[Message] | None:
+ calls.append(item)
+ return converted
+
+ input_item = StructuredInputItem(id="structured_1", thread_id="thread_1", created_at=datetime.now(), inputs=[])
+ before = HiddenContextItem(id="before", thread_id="thread_1", created_at=datetime.now(), content="before")
+ after = HiddenContextItem(id="after", thread_id="thread_1", created_at=datetime.now(), content="after")
+
+ result = await CustomConverter().to_agent_input([before, input_item, after])
+
+ assert calls == [input_item]
+ assert [message.text for message in result[1:-1]] == expected_texts
+ assert result[0].text == "before"
+ assert result[-1].text == "after"
+
+ @pytest.mark.parametrize("error", [ValueError("conversion failed"), asyncio.CancelledError()])
+ async def test_to_agent_input_propagates_structured_input_override_errors(self, error: BaseException) -> None:
+ """Test custom conversion failures and cancellation propagate to the caller."""
+ from chatkit.types import StructuredInputItem
+
+ class FailingConverter(ThreadItemConverter):
+ async def structured_input_to_input(self, item: StructuredInputItem) -> Message | list[Message] | None:
+ raise error
+
+ input_item = StructuredInputItem(id="structured_1", thread_id="thread_1", created_at=datetime.now(), inputs=[])
+
+ with pytest.raises(type(error)) as exception:
+ await FailingConverter().to_agent_input(input_item)
+
+ assert exception.value is error
+
async def test_to_agent_input_dispatches_supported_variants(self, converter):
"""Test thread item dispatch converts supported items and skips unsupported variants."""
from chatkit.types import (
@@ -743,6 +847,7 @@ async def test_to_agent_input_dispatches_supported_variants(self, converter):
"user",
"system",
"system",
+ "user",
]
assert result[0].text == "Assistant"
assert result[2].contents[0].result is not None
@@ -751,6 +856,11 @@ async def test_to_agent_input_dispatches_supported_variants(self, converter):
assert "Analysis: Completed" in result[5].text
assert result[6].text == "secret"
assert result[7].text == "sdk secret"
+ assert result[8].text == (
+ "A structured input request was displayed to the user with the following status: pending\n"
+ "\n"
+ "\n"
+ )
class TestSimpleToAgentInput: