On-Device AI
AirshipAI runs small, structured model evaluations that let the SDK make contextual decisions, on device by default. iOS SDK 21 beta
On-Device AI is in beta and provided as is. Please note:
- APIs are not final and may change.
- The feature may contain bugs or reliability issues.
- The feature may be modified or removed entirely.
Use of this feature is governed by the Airship Beta Services Terms.
Overview
AirshipAI is a backend-agnostic framework in the AirshipCore 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 that runs it and nowhere else. Which model that is, and therefore where that data goes, is your choice.
AirshipAI and its components live entirely in the AirshipCore module. The optional AirshipFoundationModels module only adds model backends built on Apple’s FoundationModels framework, such as the built-in on-device model and AirshipFoundationModel. See Models.
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 onDeviceAI 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, onDeviceAI is enabled automatically when you update to an SDK version that includes it. If you’ve explicitly set enabledFeatures to a specific list, onDeviceAI 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
AirshipAI.ModelProtocol. Linking the optionalAirshipFoundationModelsmodule supplies a default model backed by Apple’s FoundationModels, and an app can route some or all usages to a different one. See Models. - Usage —
AirshipAI.Usage<Subject>, a typed key that identifies what an evaluation is for, such asAirshipAI.InAppMessageSuppression.usage. Each SDK feature that runs an evaluation exposes its own usage constant. TheSubjectphantom type ties a usage to the one context provider that can register for it, so a mismatched provider is a compile-time error. See Usages for the full list. - Subject — The feature-specific data passed to a
ContextProviderfor a given evaluation, such as the in-app message being evaluated for suppression, or the text input field being classified. Each usage defines its own subject type. - Context — The app-supplied, prioritized list of text (
AirshipAI.Context, made up ofAirshipAI.Context.Items) that aContextProviderreturns 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.
The public entry point is Airship.ai, typed as AirshipAI.Manager. Use it to register context providers and, optionally, to swap in a different 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
AirshipAI.InAppMessageSuppression.usage
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 miss 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.
Scene text-input inference
AirshipAI.TextInputInference.usage
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.
Embedded view selection
AirshipAI.EmbeddedSelection.usage
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 Embedded Content — Selection.
Selection runs entirely on the device, with no reporting.
Providing context
Apps supply context to an evaluation by registering a ContextProvider, a closure that receives the evaluation’s subject and returns context for it.
public typealias ContextProvider<Subject: Sendable> = @Sendable (Subject) async -> ContextEach feature exposes a typed Usage<Subject> constant that ties a provider to the right call site at compile time, for example AirshipAI.InAppMessageSuppression.usage or AirshipAI.TextInputInference.usage. Register a provider for that usage.
func setContextProvider<S: Sendable>(
for usage: Usage<S>,
_ provider: ContextProvider<S>?
)You can also register a default provider that supplies general context, such as profile data, to any usage without one of its own. It receives no subject.
func setDefaultContextProvider(
_ provider: (@Sendable () async -> Context)?
)Pass nil in place of a provider to clear one you registered earlier.
Context 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.
Airship.ai.setContextProvider(for: AirshipAI.InAppMessageSuppression.usage) { subject in
AirshipAI.Context(items: [
AirshipAI.Context.Item(content: "Customer since: 2019", priority: -1),
AirshipAI.Context.Item(content: "Rental history: 15ft (2022), 20ft (2024)"),
])
}The SDK holds the provider until you replace or clear it, so capture self weakly if the closure reaches back into an object that owns the registration.
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.
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.
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.
Airship.ai.setEvaluationObserver { record in
guard case .completed(let output) = record.outcome else { return }
myAnalytics.track("ai_evaluation", [
"usage": record.usage.rawValue,
"output": output,
"ms": record.duration * 1000
])
}Pass nil 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-erasedAnyUsage.request— The instructions, output schema, and context that were offered to the model, plusprompt()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(AirshipJSON)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(any Error).duration— Wall-clock time across every attempt, including retries.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
Every evaluation runs against a model conforming to AirshipAI.ModelProtocol, with three options that cover most apps and can be mixed per usage with setModelResolver:
| Model | Requires | Where data goes |
|---|---|---|
| Built-in on-device model | AirshipFoundationModels, iOS 26, Apple Intelligence eligible | Stays on the device |
AirshipFoundationModel | AirshipFoundationModels, iOS 27 for Apple’s LanguageModel API | Wherever the wrapped LanguageModel runs |
Custom AirshipAI.ModelProtocol | Nothing beyond the AirshipCore module | Wherever your backend runs |
Only the built-in on-device model keeps context and prompts on the device. Private Cloud Compute sends them to Apple, and a custom ModelProtocol sends them wherever your backend runs. Account for this in your app’s data handling and privacy disclosures for any usage you route away from the on-device model.
Built-in on-device model
Linking the AirshipFoundationModels module, the only part of the SDK importing Apple’s FoundationModels framework, registers a model backed by Apple’s SystemLanguageModel, the on-device model behind Apple Intelligence, as the default. Adding it doesn’t raise your app’s minimum iOS version. Nothing else is required, and context never leaves the device. Without the module, every evaluation reports itself unavailable unless you supply your own model.
The built-in model needs iOS 26 or later on a device eligible for Apple Intelligence. Otherwise it reports .unavailable and the evaluation fails open. To cover those devices, route them to a custom model.
Apple Foundation Models
AirshipFoundationModel comes from the AirshipFoundationModels module. It adapts anything conforming to Foundation Models’ LanguageModel protocol, which requires iOS 27, so you get guided generation, schema handling, and context trimming without implementing AirshipAI.ModelProtocol yourself.
Use privateCloudCompute(reasoningLevel:) for Apple’s Private Cloud Compute model. It is larger than the on-device model and handles harder judgments, at the cost of a network round trip and a per-app request quota. Prompts and context go to Apple’s Private Cloud Compute, not to Airship.
Private Cloud Compute requires an entitlement from Apple, and eligibility is limited. Your account must be enrolled in the App Store Small Business Program, and no app on it can have passed 2 million first-time downloads. If you cross that threshold or leave the program, Apple allows six months to migrate to another solution. To request the entitlement and check the current terms, see Apple’s Accessing Private Cloud Compute.
import AirshipFoundationModels
if #available(iOS 27.0, *) {
let model = AirshipFoundationModel.privateCloudCompute(reasoningLevel: .deep)
Airship.ai.setModelResolver { _ in .custom(model) }
}Use backed(by:reasoningLevel:) to wrap any other LanguageModel, including your own conformance or a provider’s Swift package.
import AirshipFoundationModels
if #available(iOS 27.0, *) {
let model = AirshipFoundationModel.backed(by: someLanguageModel, reasoningLevel: .light)
Airship.ai.setModelResolver { _ in .custom(model) }
}Anthropic and Google both publish conformances, so Claude and Gemini reach the SDK through the same path as Apple’s own models. Both packages are in preview and their APIs may change, so check each provider’s documentation for current details.
Anthropic ships Claude for Foundation Models. Use .proxied in production so no API key ships in the app binary, and note that setting fixedEffort on the Claude model takes precedence over any reasoningLevel you pass to backed(by:).
import AirshipFoundationModels
import ClaudeForFoundationModels
if #available(iOS 27.0, *) {
let claude = ClaudeLanguageModel(
name: .opus5,
auth: .proxied(headers: ["X-App-Token": appToken]),
baseURL: URL(string: "https://api.example.com/claude")!
)
let model = AirshipFoundationModel.backed(by: claude)
Airship.ai.setModelResolver { _ in .custom(model) }
}Google exposes Gemini through the Firebase AI Logic SDK, which routes requests through your Firebase project rather than a key in the app.
import AirshipFoundationModels
import FirebaseAILogic
if #available(iOS 27.0, *) {
let ai = FirebaseAI.firebaseAI(backend: .googleAI())
let gemini = ai.geminiLanguageModel(name: "gemini-3.6-flash")
let model = AirshipFoundationModel.backed(by: gemini)
Airship.ai.setModelResolver { _ in .custom(model) }
}LanguageModel declares no availability of its own, so a model built with backed(by:) reports itself available and Airship attempts every evaluation. Pass an availability closure to map your model’s state onto AirshipAI.Availability. The Private Cloud Compute model tracks its own availability, so an evaluation is skipped while the device is ineligible or the system isn’t ready.
Custom model
Implement AirshipAI.ModelProtocol to route evaluations anywhere else, on device or off. This needs nothing beyond the AirshipCore module, so it works at any deployment target. It is also how you extend evaluations to devices the built-in model can’t serve, whether they run a version below iOS 26 or hardware that isn’t eligible for Apple Intelligence.
public protocol ModelProtocol: Sendable {
var availability: Availability { get }
var availabilityUpdates: AsyncStream<Availability> { get }
var maxAttempts: Int { get }
var responseTimeout: TimeInterval { get }
func respond(_ request: Request) async throws -> AirshipJSON
}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 finishes, maxAttempts is 3, and responseTimeout is 30 seconds across all attempts combined. Raise the budget or lower the attempt count for a backend that answers over the network, and override availability only for a model that can genuinely be unusable. Otherwise, let the failure surface from respond(_:).
A Request 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.dropLowestPriorityContextItem() and re-render the prompt before sending.
Example: OpenAI
OpenAI’s Chat Completions API supports structured outputs through response_format: {"type": "json_schema", ...} with strict: true. Strict mode has two requirements beyond the schema itself: every object needs additionalProperties: false, and every property must be listed in required. There’s no separate concept of an optional property. A property that AirshipJSONSchema marks optional has to be converted to a nullable type instead, for example "type": ["string", "null"], and kept in required:
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.
import AirshipCore
import Foundation
struct OpenAIModel: AirshipAI.ModelProtocol {
let apiKey: String
/// Roughly four characters per token, with room reserved for the response.
private static let promptCharacterBudget = 24_000
func respond(_ request: AirshipAI.Request) async throws -> AirshipJSON {
// Trim the lowest-priority context until the prompt fits the input window.
// A backend that reports overflow as an error can instead catch that error
// and drop one item per attempt, the way the SDK's own models do.
var request = request
while request.prompt().count > Self.promptCharacterBudget,
request.dropLowestPriorityContextItem() != nil {}
let body: [String: Any] = [
"model": "gpt-4o",
"response_format": [
"type": "json_schema",
"json_schema": [
"name": "airship_ai_response",
"strict": true,
"schema": try Self.strictSchema(request.schema),
],
],
"messages": [
["role": "system", "content": request.instructions],
["role": "user", "content": request.prompt()],
],
]
var urlRequest = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
urlRequest.httpMethod = "POST"
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "content-type")
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
// An error response has a different shape than OpenAICompletion, so decoding
// it without checking the status surfaces a DecodingError instead of the
// actual failure. The evaluator retries whatever this throws.
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw OpenAIModelError.requestFailed(
status: http.statusCode,
body: String(data: data, encoding: .utf8) ?? ""
)
}
let completion = try JSONDecoder().decode(OpenAICompletion.self, from: data)
guard
let content = completion.choices.first?.message.content,
let contentData = content.data(using: .utf8)
else {
throw OpenAIModelError.noContent
}
return try JSONDecoder().decode(AirshipJSON.self, from: contentData)
}
/// Converts an `AirshipJSONSchema` into the strict JSON Schema shape OpenAI requires:
/// every object gets `additionalProperties: false`, every property is listed in
/// `required`, and properties that were originally optional become nullable instead.
private static func strictSchema(_ schema: AirshipJSONSchema) throws -> Any {
toStrict(try JSONSerialization.jsonObject(with: try JSONEncoder().encode(schema)))
}
private static func toStrict(_ node: Any) -> Any {
guard var object = node as? [String: Any] else {
if let array = node as? [Any] { return array.map(toStrict) }
return node
}
if object["type"] as? String == "object" {
let properties = object["properties"] as? [String: Any] ?? [:]
let required = Set(object["required"] as? [String] ?? [])
var newProperties: [String: Any] = [:]
for (key, value) in properties {
let converted = toStrict(value)
newProperties[key] = required.contains(key) ? converted : nullable(converted)
}
object["properties"] = newProperties
object["required"] = Array(properties.keys)
object["additionalProperties"] = false
}
if let items = object["items"] {
object["items"] = toStrict(items)
}
return object
}
private static func nullable(_ schema: Any) -> Any {
guard var object = schema as? [String: Any], let type = object["type"] as? String else {
return schema
}
object["type"] = [type, "null"]
return object
}
}
private struct OpenAICompletion: Decodable {
struct Choice: Decodable {
struct Message: Decodable { let content: String }
let message: Message
}
let choices: [Choice]
}
private enum OpenAIModelError: Error {
case noContent
case requestFailed(status: Int, body: String)
}The example uses a fixed model ID for illustration. Check the provider’s current documentation for the model and endpoint you want, and adjust maxAttempts and responseTimeout to match its latency and reliability.
Routing models
Assign models per usage with setModelResolver.
Airship.ai.setModelResolver { usage in
if usage == AirshipAI.InAppMessageSuppression.usage {
return .custom(myPrivateComputeModel)
}
return .defaultModel
}ModelSelector is either .defaultModel, the SDK’s built-in model when the AirshipFoundationModels module is linked and the device is eligible, or .custom(_:) with any other ModelProtocol. Airship.ai.defaultModel exposes the built-in model directly, so a resolver can fall back only when the on-device model is unavailable.
Airship.ai.setModelResolver { usage in
if Airship.ai.defaultModel?.availability == .available {
return .defaultModel
}
return .custom(myFallbackModel)
}Pass nil to setModelResolver to clear a previously registered resolver and revert all usages to the SDK default.