diff --git a/build.gradle.kts b/build.gradle.kts
index 9dc9dcf7e..1c916cfef 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -36,6 +36,7 @@ tasks.register("runDebugUnitTest") {
dependsOn(":instantsearch:testDebugUnitTest")
dependsOn(":instantsearch-insights:testDebugUnitTest")
dependsOn(":instantsearch-compose:testDebugUnitTest")
+ dependsOn(":instantsearch-agent:jvmTest")
dependsOn(":extensions:android-paging3:testDebugUnitTest")
dependsOn(":extensions:android-loading:testDebugUnitTest")
dependsOn(":extensions:coroutines-extensions:jvmTest")
diff --git a/examples/android/build.gradle b/examples/android/build.gradle
index db62c7e76..09f32cda4 100644
--- a/examples/android/build.gradle
+++ b/examples/android/build.gradle
@@ -58,6 +58,8 @@ dependencies {
implementation project(":extensions:android-paging3")
//implementation "com.algolia:instantsearch-android-loading:$instantsearch"
implementation project(":extensions:android-loading")
+ // Experimental, standalone agent SDK (versioned independently, 0.x).
+ implementation project(":instantsearch-agent")
implementation "com.algolia.instantsearch:voice:1.1.0"
implementation "androidx.fragment:fragment-ktx:1.5.4"
@@ -91,6 +93,7 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
'-Xopt-in=com.algolia.instantsearch.ExperimentalInstantSearch',
'-Xopt-in=androidx.compose.material.ExperimentalMaterialApi',
'-opt-in=kotlinx.serialization.InternalSerializationApi',
+ '-opt-in=com.algolia.instantsearch.agent.ExperimentalAgentStudioApi',
]
}
}
diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml
index 464aeec04..f786b5f32 100644
--- a/examples/android/src/main/AndroidManifest.xml
+++ b/examples/android/src/main/AndroidManifest.xml
@@ -206,6 +206,12 @@
android:name=".showcase.compose.filter.facet.DynamicFacetShowcase"
android:parentActivityName=".showcase.compose.directory.ComposeDirectoryShowcase" />
+
+
+
+
>) =
response.hits.deserialize(DirectoryHit.serializer())
.filter { mappings.containsKey(it.objectID) }
@@ -44,7 +57,7 @@ internal fun directoryItems(response: SearchResponse, mappings: Map
listOf(DirectoryItem.Header(key)) + value.map { DirectoryItem.Item(it, mappings.getValue(it.objectID)) }
.sortedBy { it.hit.objectID }
- }
+ } + experimentalItems
internal fun Context.navigateTo(item: DirectoryItem.Item) {
val intent = Intent(this, item.dest.java).apply {
diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt
index 78d5726d2..2d8f0b9bb 100644
--- a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt
+++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/directory/DirectoryActivity.kt
@@ -26,6 +26,7 @@ class DirectoryActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_directory)
+
connection += connector.connectView(SearchBoxViewAppCompat(findViewById(R.id.searchView)))
val adapter = DirectoryAdapter()
diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentChatViewModel.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentChatViewModel.kt
new file mode 100644
index 000000000..ae8db1018
--- /dev/null
+++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentChatViewModel.kt
@@ -0,0 +1,40 @@
+package com.algolia.instantsearch.examples.android.showcase.compose.agent
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.algolia.instantsearch.agent.chat.ChatStore
+import com.algolia.instantsearch.agent.transport.AgentStudioTransport
+
+/**
+ * Owns the [ChatStore] for the Agent Studio showcase.
+ *
+ * Reuses the same Agent Studio showcase config that ships with the InstantSearch
+ * web examples (`examples/js/showcase`), so the demo works out of the box.
+ * In a real app, pass a **search-only** API key — never an admin key.
+ */
+class AgentChatViewModel : ViewModel() {
+
+ val store: ChatStore = run {
+ val transport = AgentStudioTransport.fromCredentials(
+ appId = APP_ID,
+ apiKey = SEARCH_API_KEY,
+ agentId = AGENT_ID,
+ )
+ ChatStore(transport = transport, scope = viewModelScope)
+ }
+
+ fun send(text: String) = store.send(text)
+
+ fun stop() = store.stop()
+
+ override fun onCleared() {
+ super.onCleared()
+ store.stop()
+ }
+
+ private companion object {
+ const val APP_ID = "latency"
+ const val SEARCH_API_KEY = "6be0576ff61c053d5f9a3225e2a90f76"
+ const val AGENT_ID = "eedef238-5468-470d-bc37-f99fa741bd25"
+ }
+}
diff --git a/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentStudioShowcase.kt b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentStudioShowcase.kt
new file mode 100644
index 000000000..c31867837
--- /dev/null
+++ b/examples/android/src/main/kotlin/com/algolia/instantsearch/examples/android/showcase/compose/agent/AgentStudioShowcase.kt
@@ -0,0 +1,334 @@
+package com.algolia.instantsearch.examples.android.showcase.compose.agent
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Button
+import androidx.compose.material.Card
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Send
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.algolia.instantsearch.agent.model.ChatStatus
+import com.algolia.instantsearch.agent.model.MessageRole
+import com.algolia.instantsearch.agent.model.ToolCallState
+import com.algolia.instantsearch.agent.model.ToolUIPart
+import com.algolia.instantsearch.agent.model.UIMessage
+import com.algolia.instantsearch.agent.model.UIMessagePart
+import com.algolia.instantsearch.examples.android.showcase.compose.ui.ShowcaseTheme
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+
+/**
+ * Showcase for the experimental, standalone `instantsearch-agent` SDK.
+ *
+ * Demonstrates the minimal flow: build an [com.algolia.instantsearch.agent.transport.AgentStudioTransport]
+ * from credentials, drive a [com.algolia.instantsearch.agent.chat.ChatStore] from a
+ * [androidx.lifecycle.ViewModel], and render its observable state with Compose.
+ *
+ * It reuses the Agent Studio showcase agent that ships with the InstantSearch
+ * web examples, so it works out of the box with no setup.
+ */
+class AgentStudioShowcase : AppCompatActivity() {
+
+ private val viewModel: AgentChatViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ ShowcaseTheme {
+ AgentScreen(viewModel)
+ }
+ }
+ }
+}
+
+@Composable
+private fun AgentScreen(viewModel: AgentChatViewModel) {
+ val store = viewModel.store
+ val messages by store.messages.collectAsState()
+ val status by store.status.collectAsState()
+ val error by store.error.collectAsState()
+ val suggestions by store.suggestions.collectAsState()
+
+ var input by remember { mutableStateOf("") }
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
+ }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Agent Studio (experimental)") }) },
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(messages, key = { it.id }) { MessageRow(it) }
+ if (status == ChatStatus.Submitted || status == ChatStatus.Streaming) {
+ item { CircularProgressIndicator(Modifier.padding(8.dp)) }
+ }
+ }
+
+ error?.let {
+ Text(
+ text = it.message ?: "Error",
+ color = MaterialTheme.colors.error,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ }
+
+ // Prompt suggestions ("what to ask next"). Shown only while idle, as
+ // tappable chips; clicking one sends it as a new user message — the
+ // same behavior as the web Chat widget.
+ if (status == ChatStatus.Ready && suggestions.isNotEmpty()) {
+ LazyRow(
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(suggestions, key = { it }) { suggestion ->
+ SuggestionChip(text = suggestion, onClick = { viewModel.send(suggestion) })
+ }
+ }
+ }
+
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = input,
+ onValueChange = { input = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Ask anything…") },
+ )
+ Spacer(Modifier.width(8.dp))
+ Button(
+ enabled = input.isNotBlank() && status == ChatStatus.Ready,
+ onClick = {
+ viewModel.send(input.trim())
+ input = ""
+ },
+ ) { Icon(Icons.Default.Send, contentDescription = "Send") }
+ if (status == ChatStatus.Streaming) {
+ IconButton(onClick = viewModel::stop) {
+ Icon(Icons.Default.Close, contentDescription = "Stop")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SuggestionChip(text: String, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier.clickable(onClick = onClick),
+ shape = RoundedCornerShape(16.dp),
+ border = BorderStroke(1.dp, MaterialTheme.colors.primary.copy(alpha = 0.5f)),
+ elevation = 0.dp,
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.body2,
+ color = MaterialTheme.colors.primary,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+ }
+}
+
+@Composable
+private fun MessageRow(message: UIMessage) {
+ val prefix = if (message.role == MessageRole.User) "You" else "Assistant"
+ Column {
+ Text(
+ text = prefix,
+ style = MaterialTheme.typography.caption,
+ fontWeight = FontWeight.Bold,
+ )
+ if (message.plainText.isNotEmpty()) {
+ Text(text = message.plainText)
+ }
+ message.parts.filterIsInstance().forEach { tool ->
+ ToolPart(tool.part)
+ }
+ }
+}
+
+@Composable
+private fun ToolPart(part: ToolUIPart) {
+ when (val state = part.state) {
+ is ToolCallState.OutputAvailable -> {
+ // Mirrors the web Chat widget: the `algolia_search_index` tool
+ // returns `output.hits`, which we render as a product carousel.
+ val products = if (isSearchTool(part.toolName)) {
+ extractProducts(state.output)
+ } else {
+ emptyList()
+ }
+ if (products.isNotEmpty()) {
+ ProductCarousel(products)
+ } else {
+ ToolStatusLabel(part.toolName, "done")
+ }
+ }
+ is ToolCallState.OutputError -> ToolStatusLabel(part.toolName, "error: ${state.errorText}")
+ is ToolCallState.InputAvailable -> ToolStatusLabel(part.toolName, "running…")
+ is ToolCallState.InputStreaming -> ToolStatusLabel(part.toolName, "preparing…")
+ }
+}
+
+@Composable
+private fun ToolStatusLabel(toolName: String, status: String) {
+ Text(
+ text = "🔧 $toolName • $status",
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f),
+ modifier = Modifier.padding(top = 2.dp),
+ )
+}
+
+@Composable
+private fun ProductCarousel(products: List) {
+ LazyRow(
+ contentPadding = PaddingValues(vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(products, key = { it.objectID }) { ProductCard(it) }
+ }
+}
+
+@Composable
+private fun ProductCard(product: Product) {
+ Card(
+ modifier = Modifier.width(140.dp),
+ shape = RoundedCornerShape(8.dp),
+ elevation = 2.dp,
+ ) {
+ Column {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(120.dp)
+ .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
+ .background(Color(0xFFF0F0F0)),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (product.imageUrl != null) {
+ AsyncImage(
+ model = product.imageUrl,
+ contentDescription = product.name,
+ modifier = Modifier.size(120.dp),
+ )
+ } else {
+ Text("🛍️")
+ }
+ }
+ Column(Modifier.padding(8.dp)) {
+ Text(
+ text = product.name,
+ style = MaterialTheme.typography.caption,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ product.price?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.caption,
+ color = MaterialTheme.colors.primary,
+ )
+ }
+ }
+ }
+ }
+}
+
+private data class Product(
+ val objectID: String,
+ val name: String,
+ val imageUrl: String?,
+ val price: String?,
+)
+
+/** The web showcase treats `algolia_search_index` and `algolia_search_index_*` (MCP) as product search. */
+private fun isSearchTool(toolName: String): Boolean =
+ toolName == "algolia_search_index" || toolName.startsWith("algolia_search_index_")
+
+/** Reads `output.hits[]` from the search tool result, matching the web Chat widget. */
+private fun extractProducts(output: kotlinx.serialization.json.JsonElement): List {
+ val hits = (output as? JsonObject)?.get("hits") as? JsonArray ?: return emptyList()
+ return hits.mapNotNull { element ->
+ val hit = element as? JsonObject ?: return@mapNotNull null
+ val objectID = hit.string("objectID") ?: return@mapNotNull null
+ Product(
+ objectID = objectID,
+ name = hit.string("name") ?: hit.string("title") ?: objectID,
+ imageUrl = hit.string("image") ?: hit.string("image_url") ?: hit.string("thumbnailUrl"),
+ price = hit.number("price")?.let { "$$it" },
+ )
+ }
+}
+
+private fun JsonObject.string(key: String): String? =
+ (this[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
+
+private fun JsonObject.number(key: String): String? {
+ val primitive = this[key] as? JsonPrimitive ?: return null
+ return primitive.contentOrNull?.takeIf { it.isNotBlank() && it.toDoubleOrNull() != null }
+}
diff --git a/gradle.properties b/gradle.properties
index e95fcba29..10b2e91ce 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -9,6 +9,14 @@ kotlin.mpp.stability.nowarn=true
android.useAndroidX=true
kotlin.mpp.androidSourceSetLayoutVersion=2
+# Use a newer Lint than the one bundled with AGP 8.7.2. AGP 8.7.x ships Lint
+# detectors compiled against an older Kotlin Analysis API that crash under
+# Kotlin 2.2.0 with `IncompatibleClassChangeError` (e.g.
+# FrequentlyChangingValueDetector / NonNullableMutableLiveDataDetector). Lint
+# 8.8.2 is built for the Kotlin 2.2 Analysis API and resolves the crash.
+# See https://issuetracker.google.com/issues/418014548
+android.experimental.lint.version=8.8.2
+
# Lib
GROUP=com.algolia
VERSION_NAME=4.0.2
diff --git a/instantsearch-agent/README.md b/instantsearch-agent/README.md
new file mode 100644
index 000000000..7346653ea
--- /dev/null
+++ b/instantsearch-agent/README.md
@@ -0,0 +1,169 @@
+# instantsearch-agent
+
+> [!WARNING]
+> **Experimental.** This is a standalone, early-stage library versioned
+> independently (`0.x`) from the main InstantSearch Android library (`4.x`).
+> Its public API is annotated with `@ExperimentalAgentStudioApi` and can change
+> in source- and binary-incompatible ways — including being renamed or
+> removed — before a stable `1.0` release. It is a *beta feature* per
+> [Algolia's Terms of Service ("Beta Services")](https://www.algolia.com/policies/terms/).
+
+Minimal native client for [Algolia Agent Studio][1]. Mirrors the AI SDK 5
+wire format used by `react-instantsearch`'s `` widget — without bundling
+any chat UI. Bring-your-own Compose / View code.
+
+Full documentation: see the [InstantSearch Agent Studio guide][2] on the
+Algolia docs.
+
+## Install
+
+`build.gradle.kts`:
+
+```kotlin
+dependencies {
+ // Versioned independently from the main InstantSearch 4.x line.
+ implementation("com.algolia:instantsearch-agent:0.1.0")
+ implementation("io.ktor:ktor-client-okhttp:3.3.3") // or your preferred engine
+}
+```
+
+## Opting in to the experimental API
+
+Every public declaration requires an explicit opt-in. Either annotate the
+usage site with `@OptIn(ExperimentalAgentStudioApi::class)`, or opt in
+module-wide in your `build.gradle.kts`:
+
+```kotlin
+kotlin {
+ sourceSets.all {
+ languageSettings.optIn("com.algolia.instantsearch.agent.ExperimentalAgentStudioApi")
+ }
+}
+```
+
+## Quick start (Jetpack Compose + ViewModel)
+
+```kotlin
+@OptIn(ExperimentalAgentStudioApi::class)
+class AgentChatViewModel(
+ appId: String,
+ apiKey: String,
+ agentId: String,
+) : ViewModel() {
+ private val transport = AgentStudioTransport.fromCredentials(
+ appId = appId,
+ apiKey = apiKey,
+ agentId = agentId,
+ )
+ val store = ChatStore(transport = transport, scope = viewModelScope)
+}
+
+@Composable
+fun AgentChatScreen(viewModel: AgentChatViewModel) {
+ val messages by viewModel.store.messages.collectAsState()
+ val status by viewModel.store.status.collectAsState()
+ val error by viewModel.store.error.collectAsState()
+ var input by remember { mutableStateOf("") }
+
+ Column(Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier.weight(1f),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(messages) { MessageRow(it) }
+ if (status == ChatStatus.Submitted || status == ChatStatus.Streaming) {
+ item { CircularProgressIndicator(Modifier.padding(8.dp)) }
+ }
+ }
+
+ error?.let {
+ Text(
+ text = it.message ?: "Error",
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ }
+
+ Row(Modifier.padding(8.dp)) {
+ OutlinedTextField(
+ value = input,
+ onValueChange = { input = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Ask anything…") },
+ )
+ Spacer(Modifier.width(8.dp))
+ Button(
+ enabled = input.isNotBlank() && status == ChatStatus.Ready,
+ onClick = {
+ viewModel.store.send(input.trim())
+ input = ""
+ },
+ ) { Text("Send") }
+ if (status == ChatStatus.Streaming) {
+ IconButton(onClick = viewModel.store::stop) {
+ Icon(Icons.Default.Close, contentDescription = "Stop")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageRow(message: UIMessage) {
+ val prefix = if (message.role == MessageRole.User) "🧑" else "🤖"
+ Column {
+ Text("$prefix ${message.plainText}")
+ message.parts.filterIsInstance().forEach { tool ->
+ ToolCallChip(tool.part)
+ }
+ }
+}
+
+@Composable
+private fun ToolCallChip(part: ToolUIPart) {
+ val label = when (val state = part.state) {
+ is ToolCallState.InputStreaming -> "preparing…"
+ is ToolCallState.InputAvailable -> "running…"
+ is ToolCallState.OutputAvailable -> "done"
+ is ToolCallState.OutputError -> "error: ${state.errorText}"
+ }
+ AssistChip(onClick = {}, label = { Text("${part.toolName} • $label") })
+}
+```
+
+## What's in v0.1
+
+| | |
+|---|---|
+| `AgentStudioEndpoint` | Builds the `agent-studio/1/agents/{id}/completions?compatibilityMode=ai-sdk-5` URL. |
+| `AgentStudioTransport` | Ktor-backed POST + SSE response. |
+| `SseEventStream` | `Flow` from a Ktor `ByteReadChannel`. |
+| `ChatStore` | StateFlow-backed aggregator exposing `messages`, `status`, `error`, `send`/`regenerate`/`stop`/`clear`. |
+
+## What's not yet here (planned)
+
+- Drop-in Compose chat composable.
+- Client-side tool execution (`onToolCall`).
+- Conversation persistence (DataStore / Room).
+- Suggestion data parts surfaced as a typed property.
+- Feedback (👍 / 👎) endpoint.
+
+## Versioning
+
+This library has its **own version line** (`0.x`), independent of the main
+InstantSearch Android library (`4.x`), so it can iterate quickly while
+experimental. Breaking changes can land in any `0.x` release. The version is
+set via this module's `gradle.properties` (`VERSION_NAME`) and does not affect
+the rest of the InstantSearch artifacts.
+
+### Changelog
+
+#### 0.1.0
+
+- Initial experimental release: `AgentStudioEndpoint`, `AgentStudioTransport`,
+ `SseEventStream`, and `ChatStore` with AI SDK 5 streaming support.
+
+[1]: https://www.algolia.com/doc/guides/algolia-ai/agent-studio/how-to/integration
+[2]: https://www.algolia.com/doc/guides/algolia-ai/agent-studio/how-to/instantsearch-agent-android
+
diff --git a/instantsearch-agent/build.gradle.kts b/instantsearch-agent/build.gradle.kts
new file mode 100644
index 000000000..268f9e49e
--- /dev/null
+++ b/instantsearch-agent/build.gradle.kts
@@ -0,0 +1,109 @@
+plugins {
+ kotlin("multiplatform")
+ id("com.android.library")
+ id("kotlinx-serialization")
+ id("com.vanniktech.maven.publish")
+}
+
+// Agent Studio is an EXPERIMENTAL, standalone library. It is versioned
+// independently from the rest of InstantSearch (0.x line) via the
+// module-local `gradle.properties` `VERSION_NAME` override.
+group = providers.gradleProperty("GROUP").get()
+version = providers.gradleProperty("VERSION_NAME").get()
+
+android {
+ namespace = "com.algolia.instantsearch.agent"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 23
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ testOptions.unitTests.apply {
+ isIncludeAndroidResources = true
+ isReturnDefaultValues = true
+ }
+
+ sourceSets {
+ getByName("main") {
+ manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ }
+ }
+
+ resourcePrefix = "alg_is_agent_"
+}
+
+kotlin {
+ explicitApi()
+ androidTarget()
+ jvm()
+ sourceSets {
+ all {
+ languageSettings {
+ optIn("kotlinx.serialization.ExperimentalSerializationApi")
+ optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
+ // The module opts in to its own experimental marker so its
+ // internal sources don't have to annotate every call site.
+ // Consumers still have to opt in explicitly.
+ optIn("com.algolia.instantsearch.agent.ExperimentalAgentStudioApi")
+ }
+ }
+
+ commonMain {
+ dependencies {
+ api(libs.kotlinx.coroutines.core)
+ api(libs.ktor.client.serialization.json)
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
+ }
+ }
+ commonTest {
+ dependencies {
+ implementation(libs.test.kotlin.common)
+ implementation(libs.test.kotlin.annotations)
+ implementation(libs.test.coroutines)
+ implementation(libs.test.ktor.client.mock)
+ }
+ }
+ named("jvmMain") {
+ dependencies {
+ implementation(libs.ktor.client.okhttp)
+ }
+ }
+ named("jvmTest") {
+ dependencies {
+ implementation(libs.test.kotlin.junit)
+ }
+ }
+ named("androidMain") {
+ dependencies {
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.kotlinx.coroutines.android)
+ }
+ }
+ named("androidUnitTest") {
+ dependencies {
+ implementation(libs.test.kotlin.junit)
+ implementation(libs.test.androidx.runner)
+ implementation(libs.test.androidx.ext)
+ implementation(libs.test.robolectric)
+ }
+ }
+ }
+}
+
+tasks.withType().configureEach {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8)
+ // Explicit-api strictness applies to production code only; test sources
+ // (e.g. JUnit `class …Test`) don't need explicit visibility modifiers.
+ if (!name.contains("Test")) {
+ freeCompilerArgs.addAll(listOf("-Xexplicit-api=strict"))
+ }
+ }
+}
diff --git a/instantsearch-agent/gradle.properties b/instantsearch-agent/gradle.properties
new file mode 100644
index 000000000..90f98e53f
--- /dev/null
+++ b/instantsearch-agent/gradle.properties
@@ -0,0 +1,10 @@
+# Agent Studio ships as a standalone, EXPERIMENTAL library with its own
+# version line (0.x), decoupled from the main InstantSearch version (4.x).
+# This overrides the root `VERSION_NAME` for this module only.
+# The experimental status is conveyed through the `@ExperimentalAgentStudioApi`
+# opt-in marker, not through the artifact version.
+VERSION_NAME=0.1.0
+
+POM_NAME=InstantSearch Agent
+POM_ARTIFACT_ID=instantsearch-agent
+POM_DESCRIPTION=Experimental, standalone client for Algolia Agent Studio on Android/Kotlin Multiplatform. Provides transport, streaming, and a chat state container for building agentic (conversational AI) search experiences. This API is experimental and may change in incompatible ways before a stable 1.0 release.
diff --git a/instantsearch-agent/src/androidMain/AndroidManifest.xml b/instantsearch-agent/src/androidMain/AndroidManifest.xml
new file mode 100644
index 000000000..b2d3ea123
--- /dev/null
+++ b/instantsearch-agent/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/ExperimentalAgentStudioApi.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/ExperimentalAgentStudioApi.kt
new file mode 100644
index 000000000..cb7fdcc2a
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/ExperimentalAgentStudioApi.kt
@@ -0,0 +1,48 @@
+package com.algolia.instantsearch.agent
+
+/**
+ * Marks an API of the InstantSearch Agent Studio library as **experimental**.
+ *
+ * The Agent Studio library is a standalone, early-stage module versioned
+ * independently (0.x) from the main InstantSearch library. Anything annotated
+ * with [ExperimentalAgentStudioApi] can change in source- and binary-
+ * incompatible ways — including being renamed or removed — without a major
+ * version bump, until the library reaches a stable 1.0 release.
+ *
+ * To use an experimental declaration you must explicitly opt in, either by
+ * annotating the usage site:
+ *
+ * ```
+ * @OptIn(ExperimentalAgentStudioApi::class)
+ * fun startChat() { /* ... */ }
+ * ```
+ *
+ * or, for a whole module, by adding the compiler argument
+ * `-opt-in=com.algolia.instantsearch.agent.ExperimentalAgentStudioApi`.
+ *
+ * We deliberately keep this marker local to the Agent Studio module (rather
+ * than reusing `com.algolia.instantsearch.ExperimentalInstantSearch`) so the
+ * standalone library doesn't couple its opt-in surface to the main
+ * InstantSearch version.
+ */
+@Target(
+ AnnotationTarget.CLASS,
+ AnnotationTarget.ANNOTATION_CLASS,
+ AnnotationTarget.PROPERTY,
+ AnnotationTarget.FIELD,
+ AnnotationTarget.LOCAL_VARIABLE,
+ AnnotationTarget.VALUE_PARAMETER,
+ AnnotationTarget.CONSTRUCTOR,
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.PROPERTY_GETTER,
+ AnnotationTarget.PROPERTY_SETTER,
+ AnnotationTarget.TYPEALIAS,
+)
+@Retention(AnnotationRetention.BINARY)
+@RequiresOptIn(
+ level = RequiresOptIn.Level.WARNING,
+ message = "InstantSearch Agent Studio is experimental. This API can be changed " +
+ "incompatibly or removed in any release before 1.0. Opt in with " +
+ "@OptIn(ExperimentalAgentStudioApi::class) to acknowledge this.",
+)
+public annotation class ExperimentalAgentStudioApi
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChatStore.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChatStore.kt
new file mode 100644
index 000000000..9e8a2e67c
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChatStore.kt
@@ -0,0 +1,202 @@
+package com.algolia.instantsearch.agent.chat
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+import com.algolia.instantsearch.agent.model.AgentStudioException
+import com.algolia.instantsearch.agent.model.ChatStatus
+import com.algolia.instantsearch.agent.model.MessageRole
+import com.algolia.instantsearch.agent.model.PartState
+import com.algolia.instantsearch.agent.model.UIMessage
+import com.algolia.instantsearch.agent.model.UIMessageChunk
+import com.algolia.instantsearch.agent.model.UIMessagePart
+import com.algolia.instantsearch.agent.transport.AgentStudioRequest
+import com.algolia.instantsearch.agent.transport.AgentStudioTransport
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.random.Random
+
+/**
+ * Native counterpart of the JS `Chat` class
+ * (`instantsearch.js/src/lib/chat/chat.ts`).
+ *
+ * Aggregates streamed [UIMessageChunk]s into a list of [UIMessage]s and
+ * exposes [messages], [status], and [error] as [StateFlow]s so Compose / View
+ * code can observe them.
+ *
+ * @param scope coroutine scope owning streaming jobs. Pass `viewModelScope` in
+ * Android, or a custom scope tied to the lifecycle of your screen.
+ * @param conversationId pass a stable id (prefixed `alg_cnv_` per Algolia
+ * conventions) to enable server-side conversation persistence; pass `null`
+ * to let the store generate one.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public class ChatStore(
+ private val transport: AgentStudioTransport,
+ private val scope: CoroutineScope,
+ conversationId: String? = null,
+ initialMessages: List = emptyList(),
+ private val idGenerator: () -> String = { "alg_msg_" + randomId() },
+) {
+ public val conversationId: String = conversationId ?: ("alg_cnv_" + randomId())
+
+ private val _messages = MutableStateFlow(initialMessages)
+ public val messages: StateFlow> = _messages.asStateFlow()
+
+ private val _status = MutableStateFlow(ChatStatus.Ready)
+ public val status: StateFlow = _status.asStateFlow()
+
+ private val _error = MutableStateFlow(null)
+ public val error: StateFlow = _error.asStateFlow()
+
+ /**
+ * Prompt suggestions ("what to ask next") streamed by the agent as a
+ * `data-suggestions` part. Derived from the last assistant message, matching
+ * the web `connectChat` behavior. Hosts typically show these as tappable
+ * chips while [status] is [ChatStatus.Ready].
+ */
+ public val suggestions: StateFlow> = messages
+ .map { extractSuggestions(it) }
+ .stateIn(scope, SharingStarted.Eagerly, emptyList())
+
+ private var streamingJob: Job? = null
+ private var assistantIndex: Int? = null
+
+ /**
+ * Send a user text message and start streaming the assistant response.
+ * Cancels any in-flight request.
+ */
+ public fun send(text: String): Job {
+ val userMessage = UIMessage(
+ id = idGenerator(),
+ role = MessageRole.User,
+ parts = listOf(UIMessagePart.Text(id = null, text = text, state = PartState.Done)),
+ )
+ _messages.update { it + userMessage }
+ return startStream(AgentStudioRequest.Trigger.SubmitMessage)
+ }
+
+ /** Re-issue the last user message, replacing the trailing assistant message (if any). */
+ public fun regenerate(): Job {
+ _messages.update { current ->
+ if (current.lastOrNull()?.role == MessageRole.Assistant) current.dropLast(1) else current
+ }
+ return startStream(AgentStudioRequest.Trigger.RegenerateMessage)
+ }
+
+ /** Cancel any in-flight streaming request. Status returns to [ChatStatus.Ready]. */
+ public fun stop() {
+ streamingJob?.cancel()
+ streamingJob = null
+ assistantIndex = null
+ if (_status.value != ChatStatus.Error) {
+ _status.value = ChatStatus.Ready
+ }
+ }
+
+ /** Wipe local conversation state. Does not call any server endpoint. */
+ public fun clear() {
+ stop()
+ _messages.value = emptyList()
+ _error.value = null
+ _status.value = ChatStatus.Ready
+ }
+
+ public fun clearError() {
+ _error.value = null
+ if (_status.value == ChatStatus.Error) _status.value = ChatStatus.Ready
+ }
+
+ private fun startStream(trigger: AgentStudioRequest.Trigger): Job {
+ streamingJob?.cancel()
+ _error.value = null
+ _status.value = ChatStatus.Submitted
+ assistantIndex = null
+
+ val wireMessages = _messages.value.mapNotNull { msg ->
+ val text = msg.plainText
+ if (text.isEmpty()) null
+ else AgentStudioRequest.WireMessage(id = msg.id, role = msg.role, text = text)
+ }
+
+ val request = AgentStudioRequest(
+ conversationId = conversationId,
+ messages = wireMessages,
+ trigger = trigger,
+ )
+
+ return scope.launch {
+ try {
+ transport.sendMessages(request).collect { chunk -> handleChunk(chunk) }
+ _status.value = ChatStatus.Ready
+ } catch (cancellation: kotlinx.coroutines.CancellationException) {
+ throw cancellation
+ } catch (throwable: Throwable) {
+ _status.value = ChatStatus.Error
+ _error.value = (throwable as? AgentStudioException)
+ ?: AgentStudioException.Underlying(
+ message = throwable.message ?: throwable::class.simpleName.orEmpty(),
+ cause = throwable,
+ )
+ }
+ }.also { streamingJob = it }
+ }
+
+ private fun handleChunk(chunk: UIMessageChunk) {
+ when (chunk) {
+ is UIMessageChunk.Start -> {
+ val id = chunk.messageId ?: idGenerator()
+ _messages.update { it + UIMessage(id = id, role = MessageRole.Assistant) }
+ assistantIndex = _messages.value.lastIndex
+ _status.value = ChatStatus.Streaming
+ }
+ is UIMessageChunk.Error -> {
+ _status.value = ChatStatus.Error
+ _error.value = AgentStudioException.Underlying(chunk.errorText)
+ }
+ else -> {
+ ensureAssistantMessage()
+ val index = assistantIndex ?: return
+ _messages.update { current ->
+ current.toMutableList().apply { set(index, ChunkReducer.apply(chunk, current[index])) }
+ }
+ if (_status.value == ChatStatus.Submitted) _status.value = ChatStatus.Streaming
+ }
+ }
+ }
+
+ private fun ensureAssistantMessage() {
+ if (assistantIndex == null) {
+ _messages.update { it + UIMessage(id = idGenerator(), role = MessageRole.Assistant) }
+ assistantIndex = _messages.value.lastIndex
+ }
+ }
+
+ public companion object {
+ private fun randomId(): String {
+ val bytes = ByteArray(16).also { Random.nextBytes(it) }
+ return bytes.joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }
+ }
+
+ private fun extractSuggestions(messages: List): List {
+ val message = messages.lastOrNull { it.role == MessageRole.Assistant } ?: return emptyList()
+ val part = message.parts
+ .filterIsInstance()
+ .firstOrNull { it.name == "suggestions" } ?: return emptyList()
+ val array = (part.json as? JsonObject)?.get("suggestions") as? JsonArray ?: return emptyList()
+ return array.mapNotNull { it.jsonPrimitive.contentOrNull }
+ }
+ }
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChunkReducer.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChunkReducer.kt
new file mode 100644
index 000000000..65cd5590d
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/chat/ChunkReducer.kt
@@ -0,0 +1,181 @@
+package com.algolia.instantsearch.agent.chat
+
+import com.algolia.instantsearch.agent.model.PartState
+import com.algolia.instantsearch.agent.model.ToolCallState
+import com.algolia.instantsearch.agent.model.ToolUIPart
+import com.algolia.instantsearch.agent.model.UIMessage
+import com.algolia.instantsearch.agent.model.UIMessageChunk
+import com.algolia.instantsearch.agent.model.UIMessagePart
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+
+/**
+ * Reduces a [UIMessageChunk] onto an existing assistant [UIMessage].
+ *
+ * Native equivalent of the chunk-handling switch inside `AbstractChat.processStreamChunk`
+ * in `instantsearch.js/src/lib/ai-lite/abstract-chat.ts`.
+ */
+internal object ChunkReducer {
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /**
+ * Parse accumulated `data-tool-output-delta` text into a preliminary
+ * [ToolCallState.OutputAvailable]. While the JSON is still incomplete we keep
+ * the part in [ToolCallState.InputAvailable] so the UI shows a running state.
+ */
+ private fun toolStateFromRawOutput(raw: String, previousInput: JsonElement): ToolCallState {
+ val parsed: JsonElement? = runCatching { json.parseToJsonElement(raw) }.getOrNull()
+ return if (parsed != null) {
+ ToolCallState.OutputAvailable(input = previousInput, output = parsed, preliminary = true)
+ } else {
+ ToolCallState.InputAvailable(input = previousInput)
+ }
+ }
+
+ fun apply(chunk: UIMessageChunk, message: UIMessage): UIMessage {
+ return when (chunk) {
+ is UIMessageChunk.Start, is UIMessageChunk.Error -> message
+ UIMessageChunk.StartStep -> message.copy(parts = message.parts + UIMessagePart.StepStart)
+ UIMessageChunk.FinishStep, UIMessageChunk.Finish, UIMessageChunk.Abort -> {
+ message.copy(parts = message.parts.map { part ->
+ when (part) {
+ is UIMessagePart.Text -> if (part.state == PartState.Streaming) part.copy(state = PartState.Done) else part
+ is UIMessagePart.Reasoning -> if (part.state == PartState.Streaming) part.copy(state = PartState.Done) else part
+ else -> part
+ }
+ })
+ }
+
+ is UIMessageChunk.TextStart -> message.copy(
+ parts = message.parts + UIMessagePart.Text(id = chunk.id, text = "", state = PartState.Streaming),
+ )
+ is UIMessageChunk.TextDelta -> appendToLastText(message, chunk.id, chunk.delta)
+ is UIMessageChunk.TextEnd -> updateLastText(message, chunk.id) { it.copy(state = PartState.Done) }
+
+ is UIMessageChunk.ReasoningStart -> message.copy(
+ parts = message.parts + UIMessagePart.Reasoning(id = chunk.id, text = "", state = PartState.Streaming),
+ )
+ is UIMessageChunk.ReasoningDelta -> appendToLastReasoning(message, chunk.id, chunk.delta)
+ is UIMessageChunk.ReasoningEnd -> updateLastReasoning(message, chunk.id) { it.copy(state = PartState.Done) }
+
+ is UIMessageChunk.ToolInputStart -> upsertTool(message, chunk.toolCallId,
+ insert = { ToolUIPart(chunk.toolName, chunk.toolCallId, ToolCallState.InputStreaming(null)) },
+ update = { it.copy(state = ToolCallState.InputStreaming(null)) },
+ )
+ is UIMessageChunk.ToolInputDelta -> message
+ is UIMessageChunk.ToolInputAvailable -> upsertTool(message, chunk.toolCallId,
+ insert = { ToolUIPart(chunk.toolName, chunk.toolCallId, ToolCallState.InputAvailable(chunk.input)) },
+ update = { it.copy(state = ToolCallState.InputAvailable(chunk.input)) },
+ )
+ is UIMessageChunk.ToolOutputAvailable -> upsertTool(message, chunk.toolCallId,
+ insert = {
+ ToolUIPart(
+ chunk.toolName, chunk.toolCallId,
+ ToolCallState.OutputAvailable(input = JsonNull, output = chunk.output, preliminary = chunk.preliminary),
+ )
+ },
+ update = { existing ->
+ val previousInput = (existing.state as? ToolCallState.InputAvailable)?.input
+ ?: (existing.state as? ToolCallState.OutputAvailable)?.input
+ ?: JsonNull
+ existing.copy(state = ToolCallState.OutputAvailable(previousInput, chunk.output, chunk.preliminary))
+ },
+ )
+ is UIMessageChunk.ToolError -> upsertTool(message, chunk.toolCallId,
+ insert = {
+ ToolUIPart(chunk.toolName, chunk.toolCallId, ToolCallState.OutputError(chunk.input, chunk.errorText))
+ },
+ update = { it.copy(state = ToolCallState.OutputError(chunk.input, chunk.errorText)) },
+ )
+ is UIMessageChunk.ToolOutputDelta -> upsertTool(message, chunk.toolCallId,
+ insert = {
+ val raw = chunk.delta
+ ToolUIPart(
+ toolName = chunk.toolName ?: "",
+ toolCallId = chunk.toolCallId,
+ state = toolStateFromRawOutput(raw, previousInput = JsonNull),
+ rawOutput = raw,
+ )
+ },
+ update = { existing ->
+ val raw = existing.rawOutput + chunk.delta
+ val previousInput = (existing.state as? ToolCallState.InputAvailable)?.input
+ ?: (existing.state as? ToolCallState.OutputAvailable)?.input
+ ?: JsonNull
+ existing.copy(state = toolStateFromRawOutput(raw, previousInput), rawOutput = raw)
+ },
+ )
+
+ is UIMessageChunk.SourceUrl -> message.copy(
+ parts = message.parts + UIMessagePart.SourceUrl(chunk.sourceId, chunk.url, chunk.title),
+ )
+ is UIMessageChunk.File -> message.copy(
+ parts = message.parts + UIMessagePart.File(chunk.mediaType, chunk.url, filename = null),
+ )
+ is UIMessageChunk.Data -> message.copy(
+ parts = message.parts + UIMessagePart.Data(chunk.name, chunk.id, chunk.json),
+ )
+ is UIMessageChunk.Unknown -> message.copy(
+ parts = message.parts + UIMessagePart.Unknown(chunk.typeIdentifier, chunk.json),
+ )
+ }
+ }
+
+ private fun appendToLastText(message: UIMessage, id: String, delta: String): UIMessage {
+ val index = message.parts.indexOfLast { it is UIMessagePart.Text && it.id == id }
+ return if (index >= 0) {
+ val current = message.parts[index] as UIMessagePart.Text
+ val updated = current.copy(text = current.text + delta, state = PartState.Streaming)
+ message.copy(parts = message.parts.toMutableList().apply { set(index, updated) })
+ } else {
+ message.copy(parts = message.parts + UIMessagePart.Text(id, delta, PartState.Streaming))
+ }
+ }
+
+ private fun updateLastText(message: UIMessage, id: String, transform: (UIMessagePart.Text) -> UIMessagePart.Text): UIMessage {
+ val index = message.parts.indexOfLast { it is UIMessagePart.Text && it.id == id }
+ if (index < 0) return message
+ val updated = transform(message.parts[index] as UIMessagePart.Text)
+ return message.copy(parts = message.parts.toMutableList().apply { set(index, updated) })
+ }
+
+ private fun appendToLastReasoning(message: UIMessage, id: String, delta: String): UIMessage {
+ val index = message.parts.indexOfLast { it is UIMessagePart.Reasoning && it.id == id }
+ return if (index >= 0) {
+ val current = message.parts[index] as UIMessagePart.Reasoning
+ val updated = current.copy(text = current.text + delta, state = PartState.Streaming)
+ message.copy(parts = message.parts.toMutableList().apply { set(index, updated) })
+ } else {
+ message.copy(parts = message.parts + UIMessagePart.Reasoning(id, delta, PartState.Streaming))
+ }
+ }
+
+ private fun updateLastReasoning(
+ message: UIMessage,
+ id: String,
+ transform: (UIMessagePart.Reasoning) -> UIMessagePart.Reasoning,
+ ): UIMessage {
+ val index = message.parts.indexOfLast { it is UIMessagePart.Reasoning && it.id == id }
+ if (index < 0) return message
+ val updated = transform(message.parts[index] as UIMessagePart.Reasoning)
+ return message.copy(parts = message.parts.toMutableList().apply { set(index, updated) })
+ }
+
+ private fun upsertTool(
+ message: UIMessage,
+ toolCallId: String,
+ insert: () -> ToolUIPart,
+ update: (ToolUIPart) -> ToolUIPart,
+ ): UIMessage {
+ val index = message.parts.indexOfFirst { it is UIMessagePart.Tool && it.part.toolCallId == toolCallId }
+ return if (index >= 0) {
+ val current = (message.parts[index] as UIMessagePart.Tool).part
+ val updatedPart = UIMessagePart.Tool(update(current))
+ message.copy(parts = message.parts.toMutableList().apply { set(index, updatedPart) })
+ } else {
+ message.copy(parts = message.parts + UIMessagePart.Tool(insert()))
+ }
+ }
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/ChatStatus.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/ChatStatus.kt
new file mode 100644
index 000000000..7bb206a27
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/ChatStatus.kt
@@ -0,0 +1,26 @@
+package com.algolia.instantsearch.agent.model
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+
+/**
+ * State of the chat lifecycle, mirroring the JS `ChatStatus` enum from
+ * `instantsearch.js/src/lib/ai-lite/types.ts`.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public enum class ChatStatus { Submitted, Streaming, Ready, Error }
+
+/**
+ * Errors surfaced by the agent studio client.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public sealed class AgentStudioException(message: String, cause: Throwable? = null) : Exception(message, cause) {
+ public class Http(public val status: Int, public val body: String?) :
+ AgentStudioException("HTTP $status${body?.let { " — $it" } ?: ""}")
+ public class MalformedChunk(payload: String) : AgentStudioException("Malformed chunk: $payload")
+ public class StreamClosed : AgentStudioException("Stream closed unexpectedly")
+ public class Underlying(message: String, cause: Throwable? = null) : AgentStudioException(message, cause)
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessage.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessage.kt
new file mode 100644
index 000000000..cd419d53d
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessage.kt
@@ -0,0 +1,127 @@
+package com.algolia.instantsearch.agent.model
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+import kotlinx.serialization.json.JsonElement
+
+/**
+ * Role of a message in an Agent Studio conversation.
+ *
+ * Mirrors the JS `UIMessage['role']` from
+ * `instantsearch.js/src/lib/ai-lite/types.ts`.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public enum class MessageRole {
+ System, User, Assistant;
+
+ public val wireValue: String
+ get() = when (this) {
+ System -> "system"
+ User -> "user"
+ Assistant -> "assistant"
+ }
+
+ public companion object {
+ public fun fromWire(value: String): MessageRole = when (value) {
+ "system" -> System
+ "user" -> User
+ "assistant" -> Assistant
+ else -> error("Unknown role: $value")
+ }
+ }
+}
+
+/**
+ * State of a streaming part (text, reasoning, …) inside a message.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public enum class PartState { Streaming, Done }
+
+/**
+ * A single part of an assembled [UIMessage].
+ *
+ * Mirrors the JS `UIMessagePart` discriminated union; modeled here as a sealed
+ * hierarchy with an [Unknown] fallback so newly-introduced variants don't
+ * crash old clients.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public sealed interface UIMessagePart {
+ public data class Text(val id: String?, val text: String, val state: PartState?) : UIMessagePart
+ public data class Reasoning(val id: String?, val text: String, val state: PartState?) : UIMessagePart
+ public data class SourceUrl(val sourceId: String, val url: String, val title: String?) : UIMessagePart
+ public data class File(val mediaType: String, val url: String, val filename: String?) : UIMessagePart
+ public object StepStart : UIMessagePart
+
+ /** A tool invocation, keyed by [Tool.toolCallId]. */
+ public data class Tool(val part: ToolUIPart) : UIMessagePart
+
+ /** Custom `data-` part. Payload kept as raw JSON. */
+ public data class Data(val name: String, val id: String?, val json: JsonElement) : UIMessagePart
+
+ /** Forward-compat fallback for unknown chunk types. */
+ public data class Unknown(val typeIdentifier: String, val json: JsonElement) : UIMessagePart
+}
+
+/**
+ * Lifecycle of a tool call. Mirrors the AI SDK 5 `tool-input-*` /
+ * `tool-output-*` chunk progression.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public sealed interface ToolCallState {
+ public data class InputStreaming(val partial: JsonElement?) : ToolCallState
+ public data class InputAvailable(val input: JsonElement) : ToolCallState
+ public data class OutputAvailable(
+ val input: JsonElement,
+ val output: JsonElement,
+ val preliminary: Boolean,
+ ) : ToolCallState
+ public data class OutputError(val input: JsonElement?, val errorText: String) : ToolCallState
+}
+
+/**
+ * A single tool invocation embedded in an assistant message.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public data class ToolUIPart(
+ val toolName: String,
+ val toolCallId: String,
+ val state: ToolCallState,
+ /**
+ * Raw, possibly-incomplete tool output accumulated from
+ * `data-tool-output-delta` chunks before the final `tool-output-available`
+ * arrives. Internal bookkeeping; the parsed result lives in [state].
+ */
+ val rawOutput: String = "",
+)
+
+/**
+ * An assembled message ready to be rendered by the host app.
+ *
+ * Metadata is intentionally typeless in v0.1; callers that need to decode it
+ * can do so from the original `start` chunk. We keep it a `JsonElement?` to
+ * stay generic without forcing a type parameter through the whole API.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public data class UIMessage(
+ val id: String,
+ val role: MessageRole,
+ val parts: List = emptyList(),
+ val metadata: JsonElement? = null,
+) {
+ /** Concatenated text from every [UIMessagePart.Text] part. */
+ val plainText: String
+ get() = parts.asSequence()
+ .filterIsInstance()
+ .joinToString(separator = "") { it.text }
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessageChunk.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessageChunk.kt
new file mode 100644
index 000000000..677f62ea2
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/model/UIMessageChunk.kt
@@ -0,0 +1,176 @@
+package com.algolia.instantsearch.agent.model
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * One frame parsed from the SSE stream returned by the Agent Studio
+ * completions endpoint.
+ *
+ * Mirrors `UIMessageChunk` from `instantsearch.js/src/lib/ai-lite/types.ts`.
+ * The closed set of variants below covers what the v0.1 [com.algolia.instantsearch.agent.chat.ChatStore]
+ * aggregates today; anything else falls into [Unknown] so backend additions
+ * don't break parsing.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public sealed interface UIMessageChunk {
+ public data class Start(val messageId: String?) : UIMessageChunk
+ public object StartStep : UIMessageChunk
+ public object FinishStep : UIMessageChunk
+ public object Finish : UIMessageChunk
+ public object Abort : UIMessageChunk
+
+ public data class TextStart(val id: String) : UIMessageChunk
+ public data class TextDelta(val id: String, val delta: String) : UIMessageChunk
+ public data class TextEnd(val id: String) : UIMessageChunk
+
+ public data class ReasoningStart(val id: String) : UIMessageChunk
+ public data class ReasoningDelta(val id: String, val delta: String) : UIMessageChunk
+ public data class ReasoningEnd(val id: String) : UIMessageChunk
+
+ public data class ToolInputStart(val toolName: String, val toolCallId: String) : UIMessageChunk
+ public data class ToolInputDelta(val toolName: String, val toolCallId: String, val inputTextDelta: String) : UIMessageChunk
+ public data class ToolInputAvailable(val toolName: String, val toolCallId: String, val input: JsonElement) : UIMessageChunk
+ public data class ToolOutputAvailable(
+ val toolName: String,
+ val toolCallId: String,
+ val output: JsonElement,
+ val preliminary: Boolean,
+ ) : UIMessageChunk
+
+ public data class ToolError(
+ val toolName: String,
+ val toolCallId: String,
+ val errorText: String,
+ val input: JsonElement?,
+ ) : UIMessageChunk
+
+ /**
+ * Incremental tool output, streamed as `data-tool-output-delta`. The agent
+ * sends the tool's JSON output in string fragments that must be concatenated
+ * and parsed once complete. Used by `algolia_display_results` and, for some
+ * agents, the search tool. Mirrors the same chunk in `ai-lite/types.ts`.
+ */
+ public data class ToolOutputDelta(val toolCallId: String, val toolName: String?, val delta: String) : UIMessageChunk
+
+ public data class SourceUrl(val sourceId: String, val url: String, val title: String?) : UIMessageChunk
+ public data class File(val url: String, val mediaType: String) : UIMessageChunk
+
+ public data class Data(val name: String, val id: String?, val json: JsonElement) : UIMessageChunk
+
+ public data class Error(val errorText: String) : UIMessageChunk
+
+ public data class Unknown(val typeIdentifier: String, val json: JsonElement) : UIMessageChunk
+
+ public companion object {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+
+ /**
+ * Decode a single SSE payload (the JSON after `data:`) into a chunk.
+ * Returns `null` if the payload is malformed (callers ignore those,
+ * matching the JS [parseJsonEventStream] behavior).
+ */
+ public fun decode(payload: String): UIMessageChunk? {
+ return runCatching { decodeOrThrow(payload) }.getOrNull()
+ }
+
+ public fun decodeOrThrow(payload: String): UIMessageChunk {
+ val element = json.parseToJsonElement(payload)
+ val obj = element.jsonObject
+ val type = obj["type"]?.jsonPrimitive?.contentOrNull
+ ?: error("Chunk missing `type`")
+
+ fun str(key: String): String? = obj[key]?.jsonPrimitive?.contentOrNull
+ fun bool(key: String): Boolean? = obj[key]?.jsonPrimitive?.booleanOrNull
+ fun sub(key: String): JsonElement? = obj[key]
+
+ return when (type) {
+ "start" -> Start(str("messageId"))
+ "start-step" -> StartStep
+ "finish-step" -> FinishStep
+ "finish" -> Finish
+ "abort" -> Abort
+
+ "text-start" -> TextStart(requireNotNull(str("id")))
+ "text-delta" -> TextDelta(requireNotNull(str("id")), requireNotNull(str("delta")))
+ "text-end" -> TextEnd(requireNotNull(str("id")))
+
+ "reasoning-start" -> ReasoningStart(requireNotNull(str("id")))
+ "reasoning-delta" -> ReasoningDelta(requireNotNull(str("id")), requireNotNull(str("delta")))
+ "reasoning-end" -> ReasoningEnd(requireNotNull(str("id")))
+
+ "tool-input-start" -> ToolInputStart(
+ toolName = requireNotNull(str("toolName")),
+ toolCallId = requireNotNull(str("toolCallId")),
+ )
+ // `tool-input-delta`, `tool-output-available` and `tool-error` reference an
+ // existing tool call by `toolCallId`; `toolName` is optional on the wire (the AI
+ // SDK only needs it on `tool-input-start`/`tool-input-available`). Don't require it
+ // here, otherwise these chunks get silently dropped and the products never render.
+ "tool-input-delta" -> ToolInputDelta(
+ toolName = str("toolName") ?: "",
+ toolCallId = requireNotNull(str("toolCallId")),
+ inputTextDelta = requireNotNull(str("inputTextDelta")),
+ )
+ "tool-input-available" -> ToolInputAvailable(
+ toolName = requireNotNull(str("toolName")),
+ toolCallId = requireNotNull(str("toolCallId")),
+ input = requireNotNull(sub("input")),
+ )
+ "tool-output-available" -> ToolOutputAvailable(
+ toolName = str("toolName") ?: "",
+ toolCallId = requireNotNull(str("toolCallId")),
+ output = requireNotNull(sub("output")),
+ preliminary = bool("preliminary") ?: false,
+ )
+ "tool-error" -> ToolError(
+ toolName = str("toolName") ?: "",
+ toolCallId = requireNotNull(str("toolCallId")),
+ errorText = requireNotNull(str("errorText")),
+ input = sub("input"),
+ )
+
+ "source-url" -> SourceUrl(
+ sourceId = requireNotNull(str("sourceId")),
+ url = requireNotNull(str("url")),
+ title = str("title"),
+ )
+ "file" -> File(
+ url = requireNotNull(str("url")),
+ mediaType = requireNotNull(str("mediaType")),
+ )
+ "error" -> Error(str("errorText") ?: "Unknown error")
+ "data-tool-output-delta" -> {
+ val data = sub("data")?.jsonObject
+ ToolOutputDelta(
+ toolCallId = requireNotNull(data?.get("toolCallId")?.jsonPrimitive?.contentOrNull),
+ toolName = data?.get("toolName")?.jsonPrimitive?.contentOrNull,
+ delta = data?.get("delta")?.jsonPrimitive?.contentOrNull ?: "",
+ )
+ }
+ else -> {
+ if (type.startsWith("data-")) {
+ Data(
+ name = type.removePrefix("data-"),
+ id = str("id"),
+ json = sub("data") ?: JsonObject(emptyMap()),
+ )
+ } else {
+ Unknown(type, element)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioEndpoint.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioEndpoint.kt
new file mode 100644
index 000000000..ef1db75fa
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioEndpoint.kt
@@ -0,0 +1,38 @@
+package com.algolia.instantsearch.agent.transport
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+
+/**
+ * Builds the Agent Studio completions URL for a given Algolia application
+ * and agent. Mirrors the URL shape used by `connectChat` in
+ * `instantsearch.js/src/connectors/chat/connectChat.ts`:
+ *
+ * https://{appId}.algolia.net/agent-studio/1/agents/{agentId}/completions
+ *
+ * We always request `compatibilityMode=ai-sdk-5`. Streaming is opt-in through
+ * `stream=true` (the default for [com.algolia.instantsearch.agent.chat.ChatStore]).
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public data class AgentStudioEndpoint(
+ public val appId: String,
+ public val agentId: String,
+ public val host: String = "$appId.algolia.net",
+) {
+
+ /**
+ * @param stream when `true`, the server streams chunks as SSE.
+ * @param cache when `false`, bypasses the server-side cache (used by
+ * `regenerate-message`).
+ */
+ public fun completionsUrl(stream: Boolean = true, cache: Boolean = true): String {
+ val query = buildList {
+ add("compatibilityMode" to "ai-sdk-5")
+ add("stream" to if (stream) "true" else "false")
+ if (!cache) add("cache" to "false")
+ }.joinToString("&") { "${it.first}=${it.second}" }
+
+ return "https://$host/agent-studio/1/agents/$agentId/completions?$query"
+ }
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioTransport.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioTransport.kt
new file mode 100644
index 000000000..7fcec9ec7
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/AgentStudioTransport.kt
@@ -0,0 +1,147 @@
+package com.algolia.instantsearch.agent.transport
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+import com.algolia.instantsearch.agent.model.AgentStudioException
+import com.algolia.instantsearch.agent.model.MessageRole
+import com.algolia.instantsearch.agent.model.UIMessageChunk
+import io.ktor.client.HttpClient
+import io.ktor.client.request.headers
+import io.ktor.client.request.preparePost
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpStatement
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+/**
+ * What we send in the request body.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public data class AgentStudioRequest(
+ public val conversationId: String?,
+ public val messages: List,
+ public val trigger: Trigger = Trigger.SubmitMessage,
+ /** Extra top-level body fields (e.g. `algolia.searchParameters`). */
+ public val extra: Map = emptyMap(),
+) {
+ public enum class Trigger(public val wireValue: String) {
+ SubmitMessage("submit-message"),
+ RegenerateMessage("regenerate-message");
+ }
+
+ /**
+ * On-the-wire shape of a message. We keep it separate from the assembled
+ * [com.algolia.instantsearch.agent.model.UIMessage] because the wire
+ * format only carries text parts on the user side.
+ */
+ public data class WireMessage(
+ public val id: String,
+ public val role: MessageRole,
+ public val text: String,
+ )
+}
+
+/**
+ * HTTP transport for the Agent Studio completions endpoint.
+ *
+ * Mirrors [DefaultChatTransport] from
+ * `instantsearch.js/src/lib/ai-lite/transport.ts`.
+ *
+ * @param httpClient supply your own (e.g. with a configured [HttpClient]
+ * engine, logging, or auth plugin) or use the default OkHttp engine.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public class AgentStudioTransport(
+ public val endpoint: AgentStudioEndpoint,
+ private val apiKey: String,
+ private val userAgent: String? = null,
+ private val httpClient: HttpClient = HttpClient(),
+) {
+
+ public fun sendMessages(request: AgentStudioRequest): Flow = flow {
+ val url = endpoint.completionsUrl(
+ stream = true,
+ cache = request.trigger != AgentStudioRequest.Trigger.RegenerateMessage,
+ )
+ val body = encodeBody(request)
+
+ val statement: HttpStatement = httpClient.preparePost(url) {
+ contentType(ContentType.Application.Json)
+ headers {
+ append(HttpHeaders.Accept, "text/event-stream")
+ append("x-algolia-application-id", endpoint.appId)
+ append("x-algolia-api-key", apiKey)
+ userAgent?.let { append("x-algolia-agent", it) }
+ }
+ setBody(body)
+ }
+
+ statement.execute { response ->
+ if (response.status != HttpStatusCode.OK) {
+ throw AgentStudioException.Http(response.status.value, body = null)
+ }
+ SseEventStream.fromChannel(response.bodyAsChannel()).collect { emit(it) }
+ }
+ }
+
+ private fun encodeBody(request: AgentStudioRequest): String {
+ val obj = buildJsonObject {
+ request.conversationId?.let { put("id", it) }
+ put("trigger", request.trigger.wireValue)
+ put("messages", buildJsonArray {
+ request.messages.forEach { msg ->
+ add(buildJsonObject {
+ put("id", msg.id)
+ put("role", msg.role.wireValue)
+ put("parts", buildJsonArray {
+ add(buildJsonObject {
+ put("type", "text")
+ put("text", msg.text)
+ })
+ })
+ })
+ }
+ })
+ request.extra.forEach { (key, value) -> put(key, value) }
+ }
+ return Json.encodeToString(JsonObject.serializer(), obj)
+ }
+
+ public companion object {
+ /**
+ * Convenience factory that builds a transport from raw Algolia
+ * credentials. Pass values from your existing `ClientSearch`
+ * (`applicationId.raw`, `apiKey.raw`).
+ *
+ * Important: use a **search-only** API key. Never embed admin keys
+ * in a shipping mobile app.
+ */
+ public fun fromCredentials(
+ appId: String,
+ apiKey: String,
+ agentId: String,
+ userAgent: String? = null,
+ httpClient: HttpClient = HttpClient(),
+ ): AgentStudioTransport = AgentStudioTransport(
+ endpoint = AgentStudioEndpoint(appId = appId, agentId = agentId),
+ apiKey = apiKey,
+ userAgent = userAgent,
+ httpClient = httpClient,
+ )
+ }
+}
diff --git a/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/SseEventStream.kt b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/SseEventStream.kt
new file mode 100644
index 000000000..9de885f5a
--- /dev/null
+++ b/instantsearch-agent/src/commonMain/kotlin/com/algolia/instantsearch/agent/transport/SseEventStream.kt
@@ -0,0 +1,48 @@
+package com.algolia.instantsearch.agent.transport
+
+import com.algolia.instantsearch.agent.ExperimentalAgentStudioApi
+import com.algolia.instantsearch.agent.model.UIMessageChunk
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.readUTF8Line
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+/**
+ * Convert a streaming HTTP response body into a [Flow] of [UIMessageChunk]s.
+ *
+ * The wire format is the AI SDK 5 SSE protocol:
+ *
+ * data: // a UIMessageChunk
+ * data: [DONE] // end-of-stream sentinel
+ * // SSE keep-alive, ignored
+ * event: ... // ignored
+ * id: ... // ignored
+ *
+ * Mirrors [parseJsonEventStream] from
+ * `instantsearch.js/src/lib/ai-lite/stream-parser.ts`.
+ *
+ * This is **experimental** API — see [ExperimentalAgentStudioApi].
+ */
+@ExperimentalAgentStudioApi
+public object SseEventStream {
+
+ public fun fromChannel(channel: ByteReadChannel): Flow = flow {
+ while (true) {
+ val line = channel.readUTF8Line() ?: break
+ val trimmed = line.trim()
+ if (trimmed.isEmpty()) continue
+
+ val payload = extractJsonPayload(trimmed) ?: continue
+ if (payload == "[DONE]") break
+
+ val chunk = UIMessageChunk.decode(payload) ?: continue
+ emit(chunk)
+ }
+ }
+
+ internal fun extractJsonPayload(line: String): String? = when {
+ line.startsWith("data:") -> line.removePrefix("data:").trim()
+ line.startsWith("{") -> line
+ else -> null
+ }
+}
diff --git a/instantsearch-agent/src/commonTest/kotlin/com/algolia/instantsearch/agent/ChunkReducerTest.kt b/instantsearch-agent/src/commonTest/kotlin/com/algolia/instantsearch/agent/ChunkReducerTest.kt
new file mode 100644
index 000000000..38d322520
--- /dev/null
+++ b/instantsearch-agent/src/commonTest/kotlin/com/algolia/instantsearch/agent/ChunkReducerTest.kt
@@ -0,0 +1,155 @@
+package com.algolia.instantsearch.agent
+
+import com.algolia.instantsearch.agent.chat.ChunkReducer
+import com.algolia.instantsearch.agent.model.MessageRole
+import com.algolia.instantsearch.agent.model.PartState
+import com.algolia.instantsearch.agent.model.ToolCallState
+import com.algolia.instantsearch.agent.model.UIMessage
+import com.algolia.instantsearch.agent.model.UIMessageChunk
+import com.algolia.instantsearch.agent.model.UIMessagePart
+import com.algolia.instantsearch.agent.transport.AgentStudioEndpoint
+import com.algolia.instantsearch.agent.transport.SseEventStream
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ChunkReducerTest {
+ @Test
+ fun textStreamProducesConcatenatedPart() {
+ var msg = UIMessage(id = "alg_msg_1", role = MessageRole.Assistant)
+ val chunks = listOf(
+ UIMessageChunk.StartStep,
+ UIMessageChunk.TextStart("t-1"),
+ UIMessageChunk.TextDelta("t-1", "Hello "),
+ UIMessageChunk.TextDelta("t-1", "world"),
+ UIMessageChunk.TextEnd("t-1"),
+ UIMessageChunk.FinishStep,
+ UIMessageChunk.Finish,
+ )
+ for (chunk in chunks) {
+ msg = ChunkReducer.apply(chunk, msg)
+ }
+ assertEquals("Hello world", msg.plainText)
+ val text = msg.parts.filterIsInstance().first()
+ assertEquals(PartState.Done, text.state)
+ }
+
+ @Test
+ fun toolLifecycleEndsWithOutputAvailable() {
+ var msg = UIMessage(id = "alg_msg_2", role = MessageRole.Assistant)
+ val input = buildJsonObject { put("query", JsonPrimitive("red shoes")) }
+ val output = buildJsonObject { put("hits", JsonNull) }
+ val chunks = listOf(
+ UIMessageChunk.ToolInputStart("algolia_search_index", "c-1"),
+ UIMessageChunk.ToolInputAvailable("algolia_search_index", "c-1", input),
+ UIMessageChunk.ToolOutputAvailable("algolia_search_index", "c-1", output, false),
+ )
+ for (chunk in chunks) msg = ChunkReducer.apply(chunk, msg)
+
+ val tool = msg.parts.filterIsInstance().first().part
+ val state = tool.state
+ assertTrue(state is ToolCallState.OutputAvailable, "expected OutputAvailable, got $state")
+ assertEquals(output, state.output)
+ }
+
+ @Test
+ fun toolOutputAvailableWithoutToolNameIsNotDropped() {
+ // Agent Studio's ai-sdk-5 stream omits `toolName` on `tool-output-available`
+ // (the call is already identified by `toolCallId`). Decoding must not drop it.
+ val payload = """{"type":"tool-output-available","toolCallId":"c-1",""" +
+ """"output":{"hits":[{"objectID":"1","name":"Laptop"}]}}"""
+ val chunk = UIMessageChunk.decode(payload)
+ assertNotNull(chunk)
+ assertTrue(chunk is UIMessageChunk.ToolOutputAvailable)
+ assertEquals("c-1", chunk.toolCallId)
+ }
+
+ @Test
+ fun toolOutputPreservesNameFromInputStartWhenOutputOmitsIt() {
+ var msg = UIMessage(id = "alg_msg_3", role = MessageRole.Assistant)
+ val output = buildJsonObject {
+ put(
+ "hits",
+ buildJsonArray {
+ add(buildJsonObject { put("objectID", JsonPrimitive("1")); put("name", JsonPrimitive("Laptop")) })
+ },
+ )
+ }
+ val chunks = listOf(
+ UIMessageChunk.ToolInputStart("algolia_search_index", "c-1"),
+ // `toolName` absent on the wire -> decoded as empty string
+ UIMessageChunk.ToolOutputAvailable(toolName = "", toolCallId = "c-1", output = output, preliminary = false),
+ )
+ for (chunk in chunks) msg = ChunkReducer.apply(chunk, msg)
+
+ val tool = msg.parts.filterIsInstance().single().part
+ assertEquals("algolia_search_index", tool.toolName)
+ assertTrue(tool.state is ToolCallState.OutputAvailable)
+ }
+
+ @Test
+ fun toolOutputDeltaAccumulatesAndParses() {
+ var msg = UIMessage(id = "alg_msg_4", role = MessageRole.Assistant)
+ val chunks = listOf(
+ UIMessageChunk.ToolInputStart("algolia_display_results", "c-9"),
+ UIMessageChunk.ToolOutputDelta("c-9", "algolia_display_results", "{\"intro\":\"curated\""),
+ UIMessageChunk.ToolOutputDelta("c-9", "algolia_display_results", ",\"groups\":[]}"),
+ )
+ for (chunk in chunks) msg = ChunkReducer.apply(chunk, msg)
+
+ val tool = msg.parts.filterIsInstance().single().part
+ val state = tool.state
+ assertTrue(state is ToolCallState.OutputAvailable, "expected OutputAvailable, got $state")
+ assertTrue(state.preliminary)
+ }
+
+ @Test
+ fun dataSuggestionsChunkBecomesDataPart() {
+ // `data-suggestions` (prompt chips) is stored as a generic data part so
+ // the ChatStore can derive the suggestions list from it.
+ val payload = """{"type":"data-suggestions","data":{"suggestions":["How can I do X?","What about Y?"]}}"""
+ val chunk = UIMessageChunk.decode(payload)
+ assertNotNull(chunk)
+ assertTrue(chunk is UIMessageChunk.Data)
+ assertEquals("suggestions", chunk.name)
+
+ var msg = UIMessage(id = "alg_msg_5", role = MessageRole.Assistant)
+ msg = ChunkReducer.apply(chunk, msg)
+ val part = msg.parts.filterIsInstance().single()
+ assertEquals("suggestions", part.name)
+ }
+
+ @Test
+ fun sseExtractionIgnoresKeepalivesAndDoneSentinel() {
+ assertEquals("{\"type\":\"finish\"}", SseEventStream.extractJsonPayload("data: {\"type\":\"finish\"}"))
+ assertEquals("[DONE]", SseEventStream.extractJsonPayload("data: [DONE]"))
+ assertNull(SseEventStream.extractJsonPayload("event: ping"))
+ assertNull(SseEventStream.extractJsonPayload("id: 123"))
+ }
+
+ @Test
+ fun endpointBuildsExpectedUrl() {
+ val endpoint = AgentStudioEndpoint(appId = "ABC123", agentId = "shopping-assistant")
+ assertEquals(
+ "https://ABC123.algolia.net/agent-studio/1/agents/shopping-assistant/completions" +
+ "?compatibilityMode=ai-sdk-5&stream=true",
+ endpoint.completionsUrl(stream = true, cache = true),
+ )
+ }
+
+ @Test
+ fun chunkDecodeHandlesUnknownTypeAsFallback() {
+ val chunk = UIMessageChunk.decode("""{"type":"future-feature","payload":42}""")
+ assertNotNull(chunk)
+ assertTrue(chunk is UIMessageChunk.Unknown)
+ assertEquals("future-feature", chunk.typeIdentifier)
+ }
+}
diff --git a/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt b/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt
index 77c3d7fc2..0e645dcc3 100644
--- a/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt
+++ b/instantsearch-compose/src/main/java/com/algolia/instantsearch/compose/highlighting/HighlightedString.kt
@@ -11,6 +11,10 @@ import com.algolia.instantsearch.core.highlighting.HighlightedString
*
* @param spanStyle applied highlighting style
*/
+// `tokens` is a Kotlin `List`, so `forEach` resolves to the inlined
+// `kotlin.collections.forEach` extension — not `java.lang.Iterable#forEach`
+// (API 24). The NewApi flag here is a lint false positive.
+@Suppress("NewApi")
public fun HighlightedString.toAnnotatedString(spanStyle: SpanStyle = SpanStyle(fontWeight = FontWeight.Bold)): AnnotatedString {
return with(AnnotatedString.Builder()) {
tokens.forEach { (part, isHighlighted) ->
diff --git a/settings.gradle.kts b/settings.gradle.kts
index a625cdfce..02c16c015 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -6,6 +6,7 @@ include(":instantsearch-core")
include(":instantsearch-insights")
include(":instantsearch-compose")
include(":instantsearch-utils")
+include(":instantsearch-agent")
// Extensions
include(":extensions:android-paging3")
include(":extensions:android-loading")