All Articles
CategoryiOS
Reading Time
12 min read
Published
2026-07-09
Word Count
1,865words

Grab a coffee — this one is a deep dive!

Apple Foundation Models Framework: Taking On-Device LLMs to Production

Summary

We took the Foundation Models framework introduced at WWDC25 into production with @Generable guided generation, the Tool protocol, and streaming — along with the 4096-token context limit, guardrail false positives, and the real privacy gains.

  • At WWDC25, the FoundationModels framework opened a Swift API to the ~3-billion-parameter on-device model
  • The context window is 4096 tokens; exceeding it throws an .exceededContextWindowSize error
  • @Generable/@Guide provide constrained decoding and type-safe output
  • At WWDC26 it opened up to providers like Anthropic and Google via the LanguageModel protocol
Apple Foundation Models Framework: Taking On-Device LLMs to Production

# Apple Foundation Models Framework: Taking On-Device LLMs to Production

At WWDC25, Apple opened up direct Swift API access to the ~3-billion-parameter on-device language model that powers Apple Intelligence, via the `FoundationModels` framework. A year later at WWDC26, they abstracted that API behind the LanguageModel protocol and cracked the door open to third-party providers (Anthropic, Google). In this article I focus on the three things we ran into while using the framework in a real product: type-safe output through guided generation, access to real data through the Tool protocol, and the surprises that a 4096-token context window creates in production.

Don't confuse this framework with Core ML-based custom model pipelines — there you run a model you trained yourself, here you prompt Apple's ready-made, continuously updated system model. I covered separately, in my "Core ML on-device AI pipeline" article, when each of the two approaches is called for.

Architecture: SystemLanguageModel and the Session Lifecycle

At the center of the framework sits SystemLanguageModel — a reference to the system model on the device. Every interaction runs through a LanguageModelSession, and the session keeps the conversation history internally:

swift
1import FoundationModels
2 
3let session = LanguageModelSession(
4 instructions: "You are an assistant that gives short, technical, and direct answers."
5)
6 
7let response = try await session.respond(to: "What's the main risk of SwiftData syncing with CloudKit?")
8print(response.content) // String

In production code, the first thing you need to do is not to call respond, but to check availability. The model can be "absent" for three reasons: the device isn't eligible, Apple Intelligence is turned off, or the model hasn't been downloaded yet.

swift
1switch SystemLanguageModel.default.availability {
2case .available:
3 // proceed
4case .unavailable(.deviceNotEligible):
5 showFallbackUI(reason: .unsupportedDevice)
6case .unavailable(.appleIntelligenceNotEnabled):
7 showFallbackUI(reason: .settingsRequired)
8case .unavailable(.modelNotReady):
9 showFallbackUI(reason: .downloading)
10case .unavailable:
11 showFallbackUI(reason: .unknown)
12}

Pro Tip: Run this switch at the very top of the app, alongside your feature flag logic. The "model exists but isn't downloaded" state never shows up in the simulator; on a real device it's a common scenario the first time Apple Intelligence is turned on — make sure it's on your QA checklist.

Guided Generation: @Generable and @Guide

The framework's most practical feature is guided generation, which makes the model produce a Swift struct directly instead of free-form text. The @Generable macro generates a schema at compile time, and the model conforms to that schema through constrained decoding — meaning the classic LLM integration problems like JSON parse errors, missing fields, and hallucinated enum values disappear structurally.

swift
1@Generable
2struct MeetingSummary {
3 @Guide(description: "One-sentence summary of the meeting")
4 let headline: String
5 
6 @Guide(description: "Action items, each short and in imperative form")
7 let actionItems: [String]
8 
9 @Guide(description: "Meeting priority level, 1 low 5 critical", .range(1...5))
10 let priority: Int
11}
12 
13let session = LanguageModelSession()
14let result = try await session.respond(
15 to: "Summarize these meeting notes: \(transcript)",
16 generating: MeetingSummary.self
17)
18 
19let summary = result.content // MeetingSummary, not String

@Guide constraints such as .range and the regex-based .pattern aren't just documentation — they're real constraints the model has to obey during decoding. In practice this is far more reliable than describing, in a prompt to the LLM, an API schema bound for your backend.

Pro Tip: Never make your @Generable structs your domain model directly. A mapping layer in between (MeetingSummaryMeeting) isolates schema changes and separates the fields the model produces from the fields headed to your backend — so when you want to extend the model schema, you don't break your domain model.

Streaming: Perceived Latency with Partial Generation

Fast as the 3B model may be, waiting for a long response to arrive all at once kills the UX. streamResponse yields a partial result of type T.PartiallyGenerated at every step — the compiler derives it automatically from your @Generable struct, so you get type-safe access to the fields even mid-stream:

swift
1let stream = session.streamResponse(
2 to: "Suggest 5 creative fitness app names",
3 generating: AppNameSuggestions.self
4)
5 
6for try await partial in stream {
7 // partial.names is a [String]?, update the UI as it fills in
8 await MainActor.run {
9 viewModel.names = partial.names ?? []
10 }
11}

On the SwiftUI side, this gives you the ChatGPT-style "appears as it types" effect without building an extra state machine — the for try await loop can feed your @Observable view model as-is.

Tool Calling: Feeding the Model Real Data

A 3B-parameter model can't know anything past the moment its training data was frozen, nor anything in your own private database. The Tool protocol closes that gap: you tell the model which functions it can call, and it decides for itself which tool to call in which situation and with which arguments — the framework manages the parallel and serial tool call graph automatically.

swift
1struct FindSavedArticlesTool: Tool {
2 let name = "findSavedArticles"
3 let description = "Searches among the user's saved articles."
4 let index: ArticleIndex
5 
6 @Generable
7 struct Arguments {
8 @Guide(description: "A short search phrase derived from the user's question")
9 var query: String
10 
11 @Guide(description: "Maximum number of articles to return", .range(1...5))
12 var limit: Int
13 }
14 
15 func call(arguments: Arguments) async throws -> ToolOutput {
16 let results = await index.search(arguments.query, limit: arguments.limit)
17 return ToolOutput(results.map(\.title).joined(separator: "\n"))
18 }
19}
20 
21let session = LanguageModelSession(tools: [FindSavedArticlesTool(index: articleIndex)])
22let response = try await session.respond(to: "Find the SwiftUI articles I saved last month")

The critical point here: the tool call happens on the device. Your call(arguments:) implementation may reach out to the network, but the model itself never sends raw data to Apple's servers — it only sees the arguments it produced and the result you returned.

When you define more than one tool, the framework calls them in parallel if needed and merges the results into a single final response — you don't write the orchestration logic by hand, you're only responsible for writing each tool's name and description clearly enough. In practice the most common mistake is two tools whose descriptions are too similar to each other: the model can't decide which one to call and triggers the wrong tool. Once you go past five tools, reviewing the descriptions and weeding out overlapping words pays off more than prompt engineering does.

Context Window: The 4096-Token Wall

Here we get to the thing that hurt us the most in a real product. SystemLanguageModel.default's context window is 4096 tokens — surprisingly small for a team used to GPT-4-class cloud models. When the window is exceeded, the framework doesn't silently truncate; it throws an .exceededContextWindowSize error, and that session becomes unusable from then on.

Apple Developer Technote TN3193 published a context management guide precisely to solve this problem; with iOS 26.4, a contextSize property and a tokenCount(for:) method were added to SystemLanguageModel — so you can now measure how many tokens a prompt consumes before sending it.

swift
1let model = SystemLanguageModel.default
2let promptTokens = model.tokenCount(for: longTranscript)
3 
4if promptTokens > model.contextSize - reservedForResponse {
5 longTranscript = summarizeOrTruncate(longTranscript)
6}
Scenario
Risky?
Recommendation
One-shot short prompt (summarization, classification)
Low
Send it directly, no measurement needed
Multi-turn conversation (session state accumulates)
High
tokenCount check on every turn + summarize old turns and reset the session
Long transcript/log analysis
Very high
Split into chunks, process each chunk in its own session
Tool results returning large JSON
Medium-high
Summarize the tool output before sending it to the model

Pro Tip: If you're building a multi-turn chat interface, never let the session grow without bounds. After N turns, summarize the older history with your own respond(to:generating:) call and inject it into a new session as a "context summary" — the user sees an uninterrupted flow, and you avoid the .exceededContextWindowSize crash.

Privacy: Where Is the Concrete Gain?

The biggest operational burden of cloud LLM integrations is KVKK/GDPR data processing agreements, data retention periods, and auditing "which data goes to which server." With Foundation Models, when the on-device model runs:

  • The prompt, tool arguments, and tool results never leave the device.
  • While Apple Intelligence is off, the framework already returns .unavailable — the data leakage risk is structurally absent at the framework level.
  • For larger tasks that require the server side, Private Cloud Compute steps in; Apple's claim is that these requests are verifiably not logged either, and are used only to process the request — but that's a separate trust model, not the same level of guarantee as on-device.

For health, finance, or enterprise content applications with a "user data must never go to a third party" requirement, this makes the architectural decision easier: process the sensitive text with the on-device model first, and only an anonymized summary goes to the cloud service if needed.

WWDC26: The LanguageModel Protocol and Third-Party Providers

The biggest architectural change to arrive in the framework at WWDC26 was the opening up of the model abstraction layer. Thanks to the new LanguageModel protocol, both SystemLanguageModel and server-based models can feed the same LanguageModelSession; Anthropic and Google published their own Swift packages, opening up access to their frontier models through this API. The practical upshot: you keep the same @Generable/Tool codebase and can switch from the on-device model to a cloud model whenever you need to — the guided generation and tool calling contract doesn't change.

This is a real architectural win for the team: during feature development you can prototype quickly with the on-device model, then in user testing identify the scenarios that hit the 4096-token limit or the reasoning ceiling and route only those flows to the cloud provider. Because your Tool protocol and @Generable schema don't change, that switch isn't a separate integration project — it's a single parameter change on the session initialization line.

When to Use It, When Not To

Use case
Foundation Models
Why
In-app text summarization, classification
✅ Suitable
Short prompt, low latency, works offline
Structured form/data extraction (receipts, post-OCR forms)
✅ Suitable
Guided generation gives you type safety
Simple assistant + access to the user's own data (tool calling)
✅ Suitable
Enriched response without data leaving the device
Long document/codebase analysis
⚠️ Careful
4096-token limit — chunking is mandatory
General knowledge Q&A, current events
❌ Not suitable
A 3B model doesn't have as broad a knowledge base as cloud models
Complex multilingual reasoning
⚠️ Careful
The guardrail false-positive rate is still higher than with cloud models

Conclusion

The Foundation Models framework breaks the assumption that "every AI feature connects to a cloud API" — especially for short, structured, privacy-sensitive tasks, the on-device model really is good enough in production. But the 4096-token context window and the reasoning limits of 3B parameters make it a tool used in the right place, not "the solution to every LLM need." Our rule is simple: if the user's data must not leave the device and the task is short and structured, try Foundation Models first, and the moment you hit the context or reasoning ceiling, move over to the cloud side seamlessly with the LanguageModel protocol that WWDC26 opened up.

Tags

#Swift#Foundation Models#Apple Intelligence#On-Device AI
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

Lead Mobile Engineer with 12+ years of experience. Expert in iOS, Android and cross-platform architectures with Swift, SwiftUI, Kotlin and Flutter. I build performant, user-friendly mobile apps.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

We respect your privacy. You can unsubscribe at any time.

Share