# On-Device AI

AirshipAi runs small, structured model evaluations that let the SDK make contextual decisions, on device by default. {{< badge_sdk_min android="21+" >}}

## Overview

`AirshipAi`, reached through `Airship.ai`, is a backend-agnostic interface in the `urbanairship-core` module for running small, structured-output model evaluations that let the SDK make contextual decisions, such as whether to show a message or which content to select. Evaluations run on device by default, and every evaluation fails open: if no model is available, the SDK falls back to its default behavior.

Airship never receives the app-supplied context or the raw results of an evaluation. Those go to the [model](#models) that runs it and nowhere else. Which model that is, and therefore where that data goes, is your choice.

> **Important:** These APIs are Kotlin-only. Supplying context or a model means implementing a suspend function, which Java can't express usefully.


> **Disclaimer:** On-device AI processes information stored on and derived from end users' devices. Depending on your implementation, these features may read and use personal data and other content residing locally on a user's device. Different jurisdictions have varying legal and privacy requirements regarding the access and processing of information stored on end users' terminal equipment. Please verify with your legal or regulatory compliance team that your use of on-device AI complies with applicable consent, transparency, and data minimization requirements under local laws and regulations.
> 
> This content is provided for informational purposes only and is not intended to be, nor should be relied on as, legal or compliance advice.


### Privacy Manager

`AirshipAi` is gated behind the `ON_DEVICE_AI` [Privacy Manager](https://www.airship.com/docs/developer/sdk-integration/android/data-collection/privacy-manager/) flag. If the flag is disabled, every evaluation reports itself unavailable and the SDK falls back to its default behavior. This lets you hold evaluations until a user grants consent, then enable the flag at runtime.

If your app hasn't customized `enabledFeatures` and enables all features by default, `ON_DEVICE_AI` is enabled automatically when you update to an SDK version that includes it. If you've explicitly set `enabledFeatures` to a specific list, `ON_DEVICE_AI` installs disabled until you add it yourself.

## Components

`AirshipAi` is built around four components. Each evaluation follows the same flow: Usage identifies what triggered it, Subject carries that trigger's feature-specific data, Context adds app-supplied metadata, and Model evaluates the result.

- **Model** — The backend that answers a request, conforming to `ModelAdapter`. There's no built-in model shipped with the SDK, so an app registers its own. See [Models](#models).
- **Usage** — `Usage<Subject>`, a typed key that identifies what an evaluation is for, such as `Usage.inAppMessageSuppression`. Each SDK feature that runs an evaluation exposes its own usage constant. The `Subject` type parameter ties a usage to the one context provider that can register for it, so a mismatched provider is a compile-time error. See [Usages](#usages) for the full list.
- **Subject** — The feature-specific data passed to a context provider for a given evaluation, such as the in-app message being evaluated for suppression, or the text a user typed into a form field. Each usage defines its own subject type.
- **Context** — The app-supplied, prioritized list of text (`EvaluationContext`, made up of `EvaluationContext.Item`s) that a context provider returns for a subject. The SDK merges this into the evaluation's prompt, dropping the lowest-priority items first if the prompt needs to be trimmed to fit the model's input window. See [Providing context](#providing-context).

The public entry point is `Airship.ai`, typed as `AirshipAi`. Use it to register context providers and, optionally, to supply a model.

## Usages

Each SDK feature that runs evaluations has its own usage constant, used to register a context provider for that feature, or to route the feature to a particular model.

### Scene suppression

`Usage.inAppMessageSuppression`

A Scene can define a natural-language condition that the model evaluates before it's displayed. If the condition isn't met, the Scene is cancelled, skipped, or penalized, depending on its configured missed behavior. If no model is available, the evaluation is skipped and the Scene displays.

The prompt is visible to the model because it's defined in the dashboard and sent to the SDK as part of the Scene. Airship only receives an `ai_suppressed` event when a message is suppressed, with no additional output from the model.

See [Contextual Hold AI](https://www.airship.com/docs/guides/messaging/in-app-experiences/configuration/triggers/#contextual-hold-ai) in the *In-App Experience Triggers* messaging guide.

### Scene text-input inference

`Usage.sceneTextInput`

A Scene can define a prompt and output schema evaluated against text a user enters into a form field, such as classifying free-text input. If no model is available, the evaluation is skipped and the input is accepted as entered.

The prompt and output schema are visible to the model because they're defined in the dashboard and sent to the SDK as part of the Scene. The form result reports an AI inference status of success or failure, plus an optional categorization if the Scene is opted in to report on it.

See [Classify responses for branching](https://www.airship.com/docs/guides/messaging/editors/scenes/elements/#classify-responses-for-branching) in the *Configure content elements* messaging guide.

### Embedded view selection

 (Android SDK 21+)

`Usage.embeddedSelection`

The model ranks pending embedded instances and displays the best match for a given embedded ID. Register a context provider to supply user-specific context. See [AI selection](https://www.airship.com/docs/developer/sdk-integration/android/in-app-experiences/embedded-content/#ai-selection) in *Embedded Content*.

Selection runs entirely on the device, with no reporting.

## Providing context

Apps supply context to an evaluation by registering an `EvaluationContextProvider`, a suspend function that receives the evaluation's subject and returns context for it.

Each feature exposes a typed `Usage<Subject>` constant that ties a provider to the right call site at compile time, for example `Usage.inAppMessageSuppression` or `Usage.sceneTextInput`. Register a provider for that usage.

**Register a provider for a specific usage**

```kotlin
fun <Subject> setContextProvider(
    usage: Usage<Subject>,
    provider: EvaluationContextProvider<Subject>?
)
```


You can also register a default provider that supplies general context, such as profile data, to any usage without one of its own.

**Register a fallback provider for any usage**

```kotlin
fun setDefaultContextProvider(provider: DefaultEvaluationContextProvider?)
```


A provider registered for a specific usage replaces the default provider for that usage rather than adding to it — the two are never combined. Merge the general context in yourself if you want both. Pass `null` in place of a provider to clear one you registered earlier.

`EvaluationContext` is an ordered list of items, each with a `priority`. Lower values are more important. If a prompt exceeds the model's input window, the SDK drops the lowest-priority items first, so put must-have context at a low priority value and nice-to-have context higher.

**Example context provider**

```kotlin
Airship.ai.setContextProvider(Usage.inAppMessageSuppression) { subject ->
    EvaluationContext(listOf(
        EvaluationContext.Item("Customer since: 2019", priority = -1.0),
        EvaluationContext.Item("Rental history: 15ft (2022), 20ft (2024)")
    ))
}
```


Context goes to the model that runs the evaluation and nowhere else. Airship does not receive, store, or report it. The prompt instructions and output schema for each evaluation are owned by the SDK feature, or by the Scene layout for layout-defined inference, not by the context provider.

> **Important:** The context should not include health data, financial account numbers, government IDs, or other sensitive personal information.

## Observing evaluations

Register an observer to see what an evaluation was asked and how it turned out, for reporting through your own analytics or monitoring evaluation performance, such as failure rate, retry counts, or duration.

> **Important:** An evaluation's inputs and outputs go to the observer and nowhere else. Airship does not receive or report any of it. Passing a record to a third-party analytics service sends its context off the device, even when the evaluation itself ran on-device, so make that call deliberately. The record can carry whatever your context providers put into the prompt, or whatever the model returned, so it's your responsibility to handle any personal data in it according to your own privacy and data-handling policies.


**Register an evaluation observer**

```kotlin
Airship.ai.setEvaluationObserver { record ->
    val output = (record.outcome as? EvaluationRecord.Outcome.Completed)?.output ?: return@setEvaluationObserver
    myAnalytics.track("ai_evaluation", mapOf(
        "usage" to record.usage.rawValue,
        "output" to output,
        "ms" to record.durationMillis
    ))
}
```


Pass `null` to clear a previously registered observer.

The observer fires once per evaluation, for every usage and every outcome, including evaluations that never reached a model. An unavailable model is the most common outcome in the field and the one worth knowing about, so it reports too, with `attempts` at `0`.

Each `EvaluationRecord` carries:

- **`usage`** — Which feature ran the evaluation, as a type-erased `Usage<*>`.
- **`request`** — The instructions, output schema, and context that were offered to the model, plus `prompt()` to render them. The context here is what the evaluation offered, not necessarily what the model used: a model that trims to fit its input window does so on its own copy, and retries can trim differently, so a record with more than one attempt has no single prompt.
- **`outcome`** — `Completed(output)` with the raw model output captured before it's decoded into the feature's own type, so output the feature can't decode is still visible; `Skipped(reason)`; or `Failed(error)`.
- **`duration`** — Wall-clock time across every attempt, including retries. `durationMillis` is the Java-friendly equivalent.
- **`attempts`** — How many times the model was called. More than one means the output failed schema validation, or the model threw, and the evaluator retried.

A feature that re-evaluates, such as an embedded view re-ranking as content comes and goes, produces a record each time. Dedupe before treating these as impressions.

## Models

Android currently supports bring-your-own-model only: every evaluation runs against a `ModelAdapter` your app supplies, on device or off. Implementing it needs nothing beyond the `urbanairship-core` module, so it works at any deployment target.

> **Note:** The SDK doesn't yet ship a built-in on-device model for Android. Gemini Nano, the on-device model behind Android's AICore, is still in beta, so there's no stable platform API to wrap. Until Airship adds built-in support, route evaluations to your own model — see [Example: Gemini](#example-gemini) for a network-backed option, or wrap Gemini Nano directly yourself if you've enrolled in its beta.


**ModelAdapter**

```kotlin
interface ModelAdapter {
    val availability: ModelAvailability
    val availabilityUpdates: Flow<ModelAvailability>

    fun retryDecision(usage: Usage<*>, error: Throwable, attempt: Int): RetryDecision
    suspend fun respond(request: ModelRequest): JsonValue
}
```


Only `respond` is required. The rest have defaults suited to a backend with no readiness state of its own: `availability` is `Available`, `availabilityUpdates` emits that once and completes, and `retryDecision` retries a schema mismatch immediately and backs off 1s then 4s on any other error, failing after 3 attempts. Override `retryDecision` for a backend where a retry is expensive or slow, or that wants a different schedule, and override `availability` only for a model that can genuinely be unusable. Otherwise, let the failure surface from `respond`.

The framework calls `retryDecision` each time `respond` throws, or the response fails schema validation, wrapped as `SchemaValidationException` so a model can tell the two failures apart and retry each on its own schedule. Return `RetryDecision.Retry(after)` with a delay, `Duration.ZERO` for immediately, or `RetryDecision.Fail` to give up. There's no separate attempt cap, so cap it yourself by returning `Fail` once `attempt` says to stop. To fall back to the framework's own policy for some errors, call `RetryDecision.defaultBackoff(error, attempt)` from inside your override. Whatever `retryDecision` returns, the evaluator still enforces a 120-second hard ceiling on total wall-clock time across every attempt, as a backstop against a pathological hang, not something a model configures.

A `ModelRequest` carries the evaluation's instructions, output schema, and prioritized context, and renders itself into prompt text with `request.prompt()`. At minimum, `respond` sends that prompt to whatever backend the model wraps and returns a JSON response that conforms to `request.schema`. If the backend has a limited input window, shrink the request with `request.droppingLowestPriorityContextItem()` and re-render the prompt before sending.

> **Important:** Whichever model you register, and wherever it runs, is where the evaluation's context and prompt go. Account for this in your app's data handling and privacy disclosures for any model that isn't strictly on-device.


### Example: Gemini

Gemini's `generateContent` API supports structured output through a `responseMimeType` of `application/json` paired with a `responseSchema`.

> **Warning:** Don't embed a provider API key directly in your app. A key shipped in the app binary can be extracted and used by anyone, and every request from the app would need to carry it over the network. Proxy the request through your own backend, and have the app authenticate to that backend instead.


```kotlin
class GeminiModel(
    private val apiKey: String,
    private val model: String = "gemini-3.5-flash"
) : ModelAdapter {

    override suspend fun respond(request: ModelRequest): JsonValue = withContext(Dispatchers.IO) {
        val body = jsonMapOf(
            // Gemini keeps the system instructions off the conversation turns rather than
            // giving them a role of their own.
            "systemInstruction" to jsonMapOf(
                "parts" to jsonListOf(jsonMapOf("text" to request.instructions))
            ),
            "contents" to jsonListOf(
                jsonMapOf(
                    "role" to "user",
                    "parts" to jsonListOf(jsonMapOf("text" to request.prompt()))
                )
            ),
            // Both keys are needed: the schema alone still yields prose unless the response
            // MIME type asks for JSON.
            "generationConfig" to jsonMapOf(
                "responseMimeType" to "application/json",
                "responseSchema" to responseSchema(request.schema)
            )
        )

        parseContent(post(body))
    }

    private suspend fun post(body: JsonMap): JsonValue {
        val url = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent"
        val connection = (URL(url).openConnection() as HttpURLConnection).apply {
            requestMethod = "POST"
            // Header rather than the `?key=` query parameter the API also accepts: a key in a
            // URL ends up in logs and crash reports.
            setRequestProperty("x-goog-api-key", apiKey)
            setRequestProperty("Content-Type", "application/json")
            doOutput = true
        }

        try {
            connection.outputStream.use { it.write(body.toString().toByteArray()) }

            val status = connection.responseCode
            val stream = if (status in 200..299) connection.inputStream else connection.errorStream
            val response = stream?.bufferedReader()?.use { it.readText() } ?: ""

            if (status !in 200..299) {
                throw IOException("Gemini request failed ($status): $response")
            }

            return JsonValue.parseString(response)
        } finally {
            connection.disconnect()
        }
    }

    /**
     * The first candidate's text, which the JSON response MIME type guarantees is JSON.
     *
     * A candidate with no parts is the shape a safety block takes, so the `finishReason` is
     * worth surfacing rather than reporting this as malformed JSON.
     */
    private fun parseContent(response: JsonValue): JsonValue {
        val candidate = response.optMap()
            .opt("candidates").optList()
            .firstOrNull()?.optMap()
            ?: throw IOException("Gemini response had no candidates: $response")

        val text = candidate
            .opt("content").optMap()
            .opt("parts").optList()
            .firstOrNull()?.optMap()
            ?.get("text")?.string
            ?: throw IOException(
                "Gemini candidate had no text (finishReason=${candidate.opt("finishReason").string})"
            )

        return JsonValue.parseString(text)
    }

    /**
     * Converts an [AirshipJsonSchema] into Gemini's `responseSchema`.
     *
     * Gemini takes `required` as authored, so an optional property stays optional, unlike a
     * strict-mode conversion that would have to make it nullable instead.
     */
    private fun responseSchema(schema: AirshipJsonSchema): JsonMap = when (val type = schema.type) {
        is AirshipJsonSchema.ValueType.ObjectType -> jsonMapOf(
            "type" to "object",
            "description" to schema.description,
            "properties" to type.properties?.let { properties ->
                JsonMap(properties.mapValues { responseSchema(it.value).toJsonValue() })
            },
            "required" to type.required
        )
        is AirshipJsonSchema.ValueType.ArrayType -> jsonMapOf(
            "type" to "array",
            "description" to schema.description,
            "items" to responseSchema(type.items)
        )
        is AirshipJsonSchema.ValueType.StringType -> jsonMapOf(
            "type" to "string",
            "description" to schema.description,
            "enum" to type.choices
        )
        AirshipJsonSchema.ValueType.BooleanType -> jsonMapOf("type" to "boolean", "description" to schema.description)
        AirshipJsonSchema.ValueType.IntegerType -> jsonMapOf("type" to "integer", "description" to schema.description)
        AirshipJsonSchema.ValueType.NumberType -> jsonMapOf("type" to "number", "description" to schema.description)
    }
}
```


The example uses a fixed model ID for illustration. Check the provider's current documentation for the model and endpoint you want, and override `retryDecision` to match its latency and reliability.

## Routing models

Register the model to use with `setModelResolver`. There's no default to fall back to — a usage with no resolved model reports itself unavailable and the evaluation is skipped.

**Route usages to a model**

```kotlin
Airship.ai.setModelResolver { usage ->
    if (usage == Usage.inAppMessageSuppression) {
        ModelSelector.Custom(myPrivateComputeModel)
    } else {
        ModelSelector.Custom(myDefaultModel)
    }
}
```


Pass `null` to `setModelResolver` to clear a previously registered resolver, reverting every usage to no model at all.
