Build agentic workflows with Gemini Nano

With ML Kit's on-device agentic workflows (com.google.mlkit:genai-agent, available through the AICore Developer Preview program), you can build autonomous, multi-turn conversational agents powered by Gemini Nano and the Android AICore system service.

Built on top of the GenerativeModel class, which provides direct model access, the AgentRunner class automates complete agentic loops: planning multi-step actions, invoking typed tools (such as querying an app repository, calling a backend API, or reading a local database), evaluating tool results, and managing conversational context locally on-device with zero network latency and full user privacy. While the GenerativeModel class supports client-managed multi-turn history using a List<Content> object (with USER and MODEL roles) and a sessionId value, the AgentRunner class provides managed multi-turn sessions (using the AgentSession interface) that record in-memory conversation history automatically across turns so that you don't have to manage transcripts manually. Static tool declarations and system instructions are also automatically cached on-device, reducing time-to-first-token latency without extra configuration.

Core components

  • The Agent class: Declares instructions, available tools, and execution guardrails using the Agent.builder() method. Defaults to maxTurns = 10 automatically.
  • The AgentRunner class: Built on top of the GenerativeModel class, orchestrating autonomous reasoning loops and managed sessions with on-device models. Main-safe by default on Dispatchers.IO, with temperature defaulting to null (which defers to the model's default; pass temperature = 0.0f for greedy decoding).
  • The AgentSession interface: Scoped multi-turn conversation session handle obtained using the runner.getOrCreateSession(sessionId) method that records in-memory conversation history automatically across turns, with the runAsync(content) and close() methods.
  • The AgentEvent interface: The reactive Kotlin Flow stream emitting text tokens and tool updates.
  • The AgentTool class: Typed functions callable by the model, defined using Android AppFunctions (@AppFunctionDeclaration).
  • The GenAiException class: Typed exception hierarchy (com.google.mlkit.genai.common.GenAiException) signaling failure states such as MAX_TURNS_EXCEEDED, NOT_AVAILABLE, or BUSY.

Prerequisites and dependencies

Ensure your development environment and Gradle configuration meet the following requirements before integrating the Agent API.

Platform and hardware requirements

Gradle configuration

Add the genai-agent dependency to your module's build.gradle.kts file:

dependencies {
    // Gemini Nano agentic workflows
    implementation("com.google.mlkit:genai-agent:1.0.0-alpha1")

    // Optional: Only required when defining tools using Android AppFunctions
    implementation("androidx.appfunctions:appfunctions:1.0.0-alpha13")
    ksp("androidx.appfunctions:appfunctions-compiler:1.0.0-alpha13")
}

While genai-agent itself does not require an annotation processor or compiler plugin, defining tools with androidx.appfunctions requires the appfunctions-compiler KSP plugin.

Quickstart: Initialize an agent

The following code sample shows how to instantiate an on-device agent and runner:

import com.google.mlkit.genai.agent.Agent
import com.google.mlkit.genai.agent.AgentRunner

// Configure the agent with system instructions
val agent = Agent.builder()
    .setInstructions("You are a helpful on-device assistant.")
    .build()

// Create the runner to execute prompts
val runner = AgentRunner(agent)

Define tools

The model can call typed tools to query app data, communicate with backend services, or trigger in-app actions. Tools are defined declaratively using Android AppFunctions (androidx.appfunctions):

package com.example.ai.agent.tools

import androidx.appfunctions.AppFunctionDeclaration
import androidx.appfunctions.AppFunctionSerializable
import androidx.appfunctions.AppFunctionService
import androidx.appfunctions.AppFunctionServiceEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@AppFunctionSerializable
data class OrderDetails(
    val orderId: String,
    val status: String,
    val eta: String,
)

@AppFunctionServiceEntryPoint(
    serviceName = "OrderAppFunctionService",
    appFunctionXmlFileName = "order_app_functions",
)
abstract class OrderTools : AppFunctionService() {

    /**
     * Fetches details and delivery status for an in-app order.
     *
     * @param orderId The alphanumeric order identifier
     */
    @AppFunctionDeclaration(isDescribedByKDoc = true)
    suspend fun fetch_order_details(
        orderId: String
    ): OrderDetails = withContext(Dispatchers.IO) {
        // Query local database or call your backend API
        OrderDetails(
            orderId = orderId,
            status = "Out for delivery",
            eta = "2:30 PM",
        )
    }
}

Register the generated service in your app's AndroidManifest.xml file:

<service
    android:name=".OrderAppFunctionService"
    android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
    android:exported="true">
    <property
        android:name="android.app.appfunctions.schema"
        android:value="app_functions_schema.xsd" />
    <property
        android:name="android.app.appfunctions.v2"
        android:value="order_app_functions.xml" />
    <intent-filter>
        <action android:name="android.app.appfunctions.AppFunctionService" />
    </intent-filter>
</service>

Restrict caller access with the ACCESS_LEVEL_SELF constant

AppFunctions don't have to be public to the system or external callers. In Android, access control is handled through caller access levels rather than inspecting caller identities.

By default (using the ACCESS_LEVEL_ANDROID_TRUSTED constant), AppFunctions can be called by platform-certified on-device assistants holding the EXECUTE_APP_FUNCTIONS system permission. If your tool is strictly intended for your app's internal agent (for example, managing user carts or private database records), restrict caller access using the ACCESS_LEVEL_SELF constant:

import androidx.appfunctions.AppFunctionAccessLevel
import androidx.appfunctions.AppFunctionDeclaration
import androidx.appfunctions.AppFunctionService
import androidx.appfunctions.AppFunctionServiceEntryPoint
import androidx.appfunctions.ExperimentalAppFunctionsApi
import androidx.appfunctions.metadata.AppFunctionMetadata

@AppFunctionServiceEntryPoint(
    serviceName = "OrderAppFunctionService",
    appFunctionXmlFileName = "order_app_functions",
)
abstract class OrderTools : AppFunctionService() {

    /**
     * Cancels an order. Restricted strictly to this app's internal agent.
     */
    @OptIn(ExperimentalAppFunctionsApi::class)
    @AppFunctionDeclaration(isDescribedByKDoc = true)
    @AppFunctionAccessLevel(
        level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
        isCompatEnforcementEnabled = true,
    )
    fun cancel_order(
        orderId: String
    ): String {
        // Only your app's internal agent can invoke this function
        return "Order $orderId cancelled successfully."
    }
}

When restricted with the ACCESS_LEVEL_SELF constant, the platform blocks external surfaces and system assistants from discovering or executing the function over IPC.

Configure the agent

Configure an Agent instance with system instructions and enable AppFunctions, then initialize the AgentRunner instance:

import com.google.mlkit.genai.agent.Agent
import com.google.mlkit.genai.agent.AgentRunner

// Configure the agent with system instructions and enable AppFunctions
val agent = Agent.builder()
    .setName("OrderAssistant")
    .setInstructions(
        "You are a helpful assistant for looking up store orders. " +
        "Use available tools to fetch order information."
    )
    .enableAppFunctions(true) // Automatically discovers all registered AppFunctions in your app
    .build()

// Initialize the runner
val runner = AgentRunner(agent)

Optionally, call the addAppFunctionIds("fetch_order_details", "cancel_order") method to scope discovery strictly to specific AppFunction IDs instead of exposing all in-app functions.

Agent defaults

  • Turn limits: The Agent.Builder class defaults maxTurns = 10 automatically as a termination condition to prevent infinite tool-calling loops. You don't need to call the setMaxTurns(10) method manually for standard conversational or tool-calling workflows.
  • Deterministic greedy decoding: The AgentRunner class sets the default temperature parameter to null, deferring to the underlying model's default. For deterministic tool selection and JSON schema compliance on-device, pass temperature = 0.0f (temperature) to the AgentRunner constructor. Avoid setting cloud-style temperatures (> 0.4f), which increase hallucination risks and schema degradation on mobile models.

Multi-turn execution and streaming events

Obtain an AgentSession instance using the runner.getOrCreateSession(sessionId) method inside an Android ViewModel class. The session returns a Flow<AgentEvent> stream emitting real-time text tokens and tool execution updates:

package com.example.ai.agent

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.mlkit.genai.agent.AgentEvent
import com.google.mlkit.genai.agent.AgentRunner
import com.google.mlkit.genai.agent.AgentSession
import com.google.mlkit.genai.common.GenAiException
import com.google.mlkit.genai.prompt.Content
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class AgentUiState(
    val messages: List<String> = emptyList(),
    val currentResponse: String = "",
    val activeToolStatus: String? = null,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

class ChatViewModel(
    private val runner: AgentRunner,
    private val sessionId: String = "chat_session"
) : ViewModel() {

    // Scoped session preserves conversation history across configuration changes (screen rotations)
    private val session: AgentSession = runner.getOrCreateSession(sessionId)

    private val _uiState = MutableStateFlow(AgentUiState())
    val uiState: StateFlow<AgentUiState> = _uiState.asStateFlow()

    fun sendMessage(userText: String) {
        _uiState.update {
            it.copy(
                messages = it.messages + userText,
                currentResponse = "",
                activeToolStatus = null,
                isLoading = true,
                errorMessage = null
            )
        }

        viewModelScope.launch {
            val content = Content.builder().text(userText).build()
            session.runAsync(content)
                .catch { e ->
                    val message = when {
                        e is GenAiException && e.errorCode == GenAiException.ErrorCode.MAX_TURNS_EXCEEDED ->
                            "Agent exceeded the maximum reasoning turns allowed."
                        e is GenAiException && e.errorCode == GenAiException.ErrorCode.NOT_AVAILABLE ->
                            "On-device AI is currently unavailable."
                        e is GenAiException && e.errorCode == GenAiException.ErrorCode.BUSY ->
                            "On-device AI is busy. Please try again."
                        else -> e.localizedMessage ?: "Agent error"
                    }
                    _uiState.update {
                        it.copy(
                            isLoading = false,
                            activeToolStatus = null,
                            errorMessage = message
                        )
                    }
                }
                .collect { event ->
                    when (event) {
                        is AgentEvent.TextMessage -> {
                            _uiState.update {
                                it.copy(
                                    currentResponse = if (event.isPartial) {
                                        it.currentResponse + event.text
                                    } else {
                                        event.text.ifEmpty { it.currentResponse }
                                    },
                                    activeToolStatus = null
                                )
                            }
                        }
                        is AgentEvent.FunctionCall -> {
                            _uiState.update {
                                it.copy(activeToolStatus = "Executing ${event.name}...")
                            }
                        }
                        is AgentEvent.FunctionResponse -> {
                            _uiState.update {
                                it.copy(activeToolStatus = "Received data from ${event.name}")
                            }
                        }
                        else -> {}
                    }
                }

            // Finalize message once stream completes
            _uiState.update {
                it.copy(
                    messages = it.messages + it.currentResponse,
                    currentResponse = "",
                    activeToolStatus = null,
                    isLoading = false
                )
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        // Cleanly evicts conversation memory from RAM when ViewModel is destroyed
        session.close()
    }
}

Stream text tokens directly

For conversational interfaces where tool execution details don't need to be displayed separately in the UI, use the textTokens() extension function on the Flow<AgentEvent> interface:

import com.google.mlkit.genai.agent.textTokens
import com.google.mlkit.genai.prompt.Content

val content = Content.builder().text("What is my order status?").build()
session.runAsync(content)
    .textTokens()
    .collect { token ->
        print(token)
    }

Single-turn execution

For one-off tasks where dialogue history shouldn't be retained in RAM (for example, photo tagging or ephemeral text summarization), call the runner.runSingleTurnAsync(content) method directly:

// Ephemeral single-turn: cleans up session state from RAM automatically upon completion
val content = Content.builder()
    .text("Summarize the order history for customer #1042: ...")
    .build()

runner.runSingleTurnAsync(content)
    .textTokens()
    .collect { token ->
        displaySummary(token)
    }

When invoking the runner.runSingleTurnAsync(content) method directly, the AgentRunner instance allocates an ephemeral session ID internally and automatically deletes the conversation state from memory upon completion, preventing memory leaks.

Session lifecycle and memory management

Multi-turn dialogue history is stored in memory and indexed by its sessionId value:

  • Configuration changes: Retaining the AgentSession instance inside an Android ViewModel class preserves conversation history across screen rotations automatically without plumbing strings on every turn.
  • Session eviction: Call the session.delete() method to synchronously evict a session when a conversation thread is dismissed, or call the non-suspending session.close() method in the ViewModel.onCleared() method to free RAM.
  • Bulk eviction: Call the runner.deleteSession(sessionId) method to free a specific session, or the runner.clearAllSessions() method to evict all cached conversations.
  • Runner lifecycle: The AgentRunner class implements the AutoCloseable interface. When sharing a runner across screens, close individual AgentSession handles in each screen's ViewModel.onCleared() method, and call the runner.close() method when shutting down the runner completely.
  • Concurrent sessions: A single AgentRunner instance can host multiple distinct sessions simultaneously. Each sessionId value maintains an isolated conversational history.

Error handling with the GenAiException class

Runtime failures during agent execution, such as when the agent reaches maximum tool turns without generating a response or when on-device AI is temporarily unavailable, are emitted or thrown as a GenAiException exception (com.google.mlkit.genai.common.GenAiException). Handle failures reactively using the Kotlin Coroutines Flow catch operator:

session.runAsync(content)
    .catch { e ->
        if (e is GenAiException) {
            when (e.errorCode) {
                GenAiException.ErrorCode.MAX_TURNS_EXCEEDED -> {
                    // Turn limit exceeded
                }
                GenAiException.ErrorCode.NOT_AVAILABLE -> {
                    // Gemini Nano or AICore unavailable
                }
                GenAiException.ErrorCode.BUSY -> {
                    // System busy or rate limit reached
                }
                else -> {
                    // Other GenAiException errors
                }
            }
        }
    }
    .collect { event ->
        // Process response events
    }

Key error codes

  • GenAiException.ErrorCode.MAX_TURNS_EXCEEDED (-112): The agent exceeded the maximum turn limit (the maxTurns limit) without generating a final response.
  • GenAiException.ErrorCode.NOT_AVAILABLE (8): The Gemini Nano model or AICore system service isn't available or ready.
  • GenAiException.ErrorCode.BUSY (9): System is busy or the calling app has exceeded its quota.