Noqta
  • Home
  • Services
  • About us
  • Writing
  • Sign in
writing/tutorial/2026/07
● TutorialJul 7, 2026·28 min read

Building On-Device AI Apps with Apple's Foundation Models Framework in Swift

Learn how to add private, on-device AI to your iOS and macOS apps with Apple's Foundation Models framework. This hands-on Swift tutorial covers availability checks, guided generation with @Generable, streaming, and tool calling — no API keys, no network, no per-token cost.

AI Bot
AI Bot
Author
·EN · FR · AR

Apple's Foundation Models framework gives every app direct access to the same on-device large language model that powers Apple Intelligence. There are no API keys, no network round-trips, and no per-token billing. Inference runs entirely on the user's device — an Apple silicon Mac, iPhone, or iPad — which means user data never leaves the device and your features keep working in airplane mode.

In this tutorial you will build a Smart Notes feature: paste in a block of raw meeting notes and the model returns a clean summary plus a structured, typed list of action items — each with an owner and a priority. Along the way you will learn the four pillars of the framework: availability checks, plain text generation, guided generation with @Generable, streaming, and tool calling.

Prerequisites

Before starting, make sure you have:

  • Xcode 26 or later — the framework ships with the latest SDK
  • A device or simulator running iOS 26+, iPadOS 26+, or macOS 26+
  • An Apple silicon device with Apple Intelligence enabled (Settings → Apple Intelligence & Siri)
  • Comfort with Swift and basic SwiftUI
  • Roughly 4 GB of free storage — the on-device model downloads automatically the first time it is needed

No Anthropic, OpenAI, or Google account is required. Everything here runs locally.

What You'll Build

By the end you will have a SwiftUI screen with a text editor and a "Summarize" button. Tapping it calls the on-device model, which returns:

  • A concise one-paragraph summary of the notes
  • A typed array of action items, each with a task, an owner, and a priority enum

Because the output is strongly typed, you can render it directly in SwiftUI with no fragile string parsing. You will also add a tool that lets the model look up team members, and a streaming mode that shows the summary appearing token by token.

Understanding the Foundation Models Framework

A quick mental model before we write code.

The on-device model

The framework exposes a compact language model — roughly three billion parameters — optimized to run on Apple silicon with quantization. It is not a frontier model like Claude or Gemini. It excels at focused, on-device tasks: summarization, classification, extraction, short rewrites, and structured generation. For those jobs it is fast, free, and completely private.

The core types

TypeRole
SystemLanguageModelRepresents the on-device model; exposes .availability
LanguageModelSessionA stateful conversation; you call respond(to:) on it
@GenerableMacro that turns a Swift type into a schema the model can fill
@GuideAdds natural-language descriptions and constraints to fields
ToolProtocol for functions the model can call during generation

Why guided generation matters

Most LLM integrations force you to ask for JSON in a prompt, then parse a string and pray it is valid. Foundation Models flips this around: you annotate a Swift struct with @Generable, hand the type to the session, and the framework constrains the model's decoding so the output is guaranteed to match your schema. You get back a real, typed Swift value — never a raw string to untangle.

Step 1: Import the Framework and Check Availability

Never assume the model is ready. A device might not be eligible, Apple Intelligence might be off, or the model might still be downloading. Always branch on availability first.

import FoundationModels
import SwiftUI
 
struct SmartNotesView: View {
    // A reference to the on-device system model.
    private let model = SystemLanguageModel.default
 
    var body: some View {
        switch model.availability {
        case .available:
            NotesEditor()
        case .unavailable(.deviceNotEligible):
            ContentUnavailableView(
                "Not Supported",
                systemImage: "cpu",
                description: Text("This device can't run on-device AI.")
            )
        case .unavailable(.appleIntelligenceNotEnabled):
            ContentUnavailableView(
                "Turn On Apple Intelligence",
                systemImage: "sparkles",
                description: Text("Enable Apple Intelligence in Settings to use Smart Notes.")
            )
        case .unavailable(.modelNotReady):
            ContentUnavailableView(
                "Downloading Model",
                systemImage: "arrow.down.circle",
                description: Text("The model is still downloading. Try again shortly.")
            )
        case .unavailable(let other):
            ContentUnavailableView(
                "Unavailable",
                systemImage: "exclamationmark.triangle",
                description: Text("On-device AI is unavailable: \(String(describing: other))")
            )
        }
    }
}

The model downloads automatically based on network status, battery level, and system load — that is why modelNotReady exists. Handle it gracefully instead of showing an error.

Step 2: Your First Generation

A LanguageModelSession represents one ongoing conversation. Create it with optional instructions — a system prompt that shapes tone and behavior — then call respond(to:).

import FoundationModels
 
func quickSummary(of notes: String) async throws -> String {
    let session = LanguageModelSession(
        instructions: """
        You are a concise meeting assistant. Summarize notes in one \
        short paragraph. Do not invent details that are not present.
        """
    )
 
    let response = try await session.respond(
        to: "Summarize these notes:\n\n\(notes)"
    )
 
    return response.content
}

respond(to:) is async throws. The returned value exposes .content for the generated text. The session keeps its own transcript, so follow-up calls on the same session remember earlier turns — handy for chat, but for a one-shot summary a fresh session per request is fine.

Step 3: Guided Generation with @Generable

Plain text is fine for a summary, but the action items need structure. Define the shape you want as @Generable Swift types. Use @Guide to describe each field so the model fills it correctly.

import FoundationModels
 
@Generable
struct NotesDigest {
    @Guide(description: "A concise one-paragraph summary of the notes.")
    let summary: String
 
    @Guide(description: "Every actionable task mentioned in the notes.")
    let actionItems: [ActionItem]
}
 
@Generable
struct ActionItem {
    @Guide(description: "A short, imperative description of the task.")
    let task: String
 
    @Guide(description: "The person responsible, or 'Unassigned' if unclear.")
    let owner: String
 
    let priority: Priority
}
 
@Generable
enum Priority {
    case low
    case medium
    case high
}

Now ask the session to generate that exact type. Pass the type as the generating: argument and the framework constrains decoding to match your schema.

func makeDigest(from notes: String) async throws -> NotesDigest {
    let session = LanguageModelSession(
        instructions: """
        Extract a summary and a list of concrete action items from \
        meeting notes. Only include tasks that are explicitly stated.
        """
    )
 
    let response = try await session.respond(
        to: "Analyze these notes:\n\n\(notes)",
        generating: NotesDigest.self
    )
 
    // response.content is a fully typed NotesDigest — no parsing.
    return response.content
}

response.content is a real NotesDigest. The priority field is a proper Priority enum, not a string you have to validate. This is the single biggest ergonomic win of the framework.

Constraining fields further

@Guide accepts more than descriptions. You can bound counts, enforce patterns, or restrict a string to a set of allowed values.

@Generable
struct TaggedNote {
    @Guide(description: "The note body.")
    let body: String
 
    // Force the model to return between 1 and 3 tags.
    @Guide(description: "Relevant topic tags.")
    @Guide(.count(3))
    let tags: [String]
}

These constraints are enforced during decoding, so the model physically cannot return four tags when you asked for three.

Step 4: Wire It into SwiftUI

Here is a compact editor screen that runs the digest and renders the typed result. Note the isResponding guard — a session processes one request at a time.

import FoundationModels
import SwiftUI
 
struct NotesEditor: View {
    @State private var notes = ""
    @State private var digest: NotesDigest?
    @State private var isWorking = false
    @State private var errorText: String?
 
    var body: some View {
        Form {
            Section("Raw Notes") {
                TextEditor(text: $notes)
                    .frame(minHeight: 160)
            }
 
            Button("Summarize") {
                Task { await run() }
            }
            .disabled(notes.isEmpty || isWorking)
 
            if let digest {
                Section("Summary") {
                    Text(digest.summary)
                }
                Section("Action Items") {
                    ForEach(Array(digest.actionItems.enumerated()), id: \.offset) { _, item in
                        VStack(alignment: .leading, spacing: 4) {
                            Text(item.task).font(.headline)
                            Text("\(item.owner) · \(String(describing: item.priority))")
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
 
            if let errorText {
                Text(errorText).foregroundStyle(.red)
            }
        }
        .overlay {
            if isWorking { ProgressView() }
        }
    }
 
    private func run() async {
        isWorking = true
        errorText = nil
        defer { isWorking = false }
        do {
            digest = try await makeDigest(from: notes)
        } catch {
            errorText = error.localizedDescription
        }
    }
}

Build and run on a supported device, paste a paragraph of messy notes, and tap Summarize. You will get a clean summary and a typed list of tasks rendered natively — all computed on-device.

Step 5: Stream the Response

For anything longer than a sentence, streaming makes the UI feel alive. Instead of respond, call streamResponse, which returns an async sequence of progressively-complete snapshots.

func streamSummary(of notes: String, onUpdate: @escaping (String) -> Void) async throws {
    let session = LanguageModelSession(
        instructions: "Summarize meeting notes clearly and briefly."
    )
 
    let stream = session.streamResponse(
        to: "Summarize these notes:\n\n\(notes)"
    )
 
    for try await partial in stream {
        // Each partial is the full text generated so far.
        onUpdate(partial.content)
    }
}

Guided generation streams too. When you stream a @Generable type, each snapshot is a partially generated version of your struct, with fields populated as they arrive — so you can render a summary while the action items are still being produced.

let stream = session.streamResponse(
    to: "Analyze these notes:\n\n\(notes)",
    generating: NotesDigest.self
)
 
for try await partial in stream {
    // partial.content.summary may be filled before actionItems is complete.
    await MainActor.run { self.livePartial = partial.content }
}

Step 6: Add a Tool

Sometimes the model needs live information it does not have — the current roster of your team, a database record, the weather. Tools let the model call your Swift code mid-generation. Conform to the Tool protocol: give it a name, a description, a @Generable Arguments type, and a call method.

import FoundationModels
 
struct TeamDirectoryTool: Tool {
    let name = "lookupTeamMember"
    let description = "Find a team member's full name and role from a partial name."
 
    @Generable
    struct Arguments {
        @Guide(description: "A partial or full name to search for.")
        var query: String
    }
 
    // A tiny stand-in for a real data source.
    private let roster = [
        "sara": "Sara Ben Ali — Product Lead",
        "youssef": "Youssef Trabelsi — Backend Engineer",
        "lina": "Lina Haddad — Designer"
    ]
 
    func call(arguments: Arguments) async throws -> String {
        let key = arguments.query.lowercased()
        if let match = roster.first(where: { key.contains($0.key) }) {
            return match.value
        }
        return "No team member found matching '\(arguments.query)'."
    }
}

Register the tool when you create the session. From then on, the model decides on its own when to call it, and the framework routes the call to your call method, feeds the result back, and continues generating.

let session = LanguageModelSession(
    tools: [TeamDirectoryTool()],
    instructions: """
    Assign action items to real team members. When a note mentions a \
    person by partial name, use the lookup tool to resolve their full name.
    """
)
 
let response = try await session.respond(
    to: "Analyze these notes:\n\n\(notes)",
    generating: NotesDigest.self
)

If your notes say "Sara to ship the pricing page," the model calls lookupTeamMember with query: "Sara", receives "Sara Ben Ali — Product Lead," and fills the owner field with the resolved name. You can inspect every step through session.transcript, which enumerates instructions, prompts, tool calls, tool outputs, and responses — invaluable for debugging.

Step 7: Tune Generation and Performance

Two small touches make a real difference in production.

Control randomness with GenerationOptions. Lower the temperature for extraction tasks where you want deterministic, faithful output.

let response = try await session.respond(
    to: prompt,
    generating: NotesDigest.self,
    options: GenerationOptions(temperature: 0.3)
)

Prewarm the session when you know a request is coming — for example when the user starts typing — so the model is resident in memory by the time they tap the button.

let session = LanguageModelSession(instructions: "…")
session.prewarm()   // Load resources ahead of the first real request.

Testing Your Implementation

To verify the feature end-to-end:

  1. Run on a physical Apple silicon device with Apple Intelligence enabled — the simulator support varies, and real hardware reflects true latency.
  2. Paste notes that mention a task and a person, e.g. "Discussed Q3 launch. Sara to finalize pricing by Friday. Youssef needs to fix the login bug — urgent."
  3. Confirm the summary is faithful and the action items array has two entries with the right owners and priorities.
  4. Toggle airplane mode and run again — it should still work, proving inference is fully on-device.
  5. Open session.transcript in the debugger to confirm the tool was invoked.

Troubleshooting

unavailable(.modelNotReady) never resolves. The model downloads opportunistically. Ensure the device is on Wi-Fi, charged, and idle for a few minutes, then relaunch.

Guided generation returns odd values. Sharpen your @Guide descriptions. The model treats them as instructions — vague descriptions produce vague fields. Add constraints like .count(_:) where structure matters.

The session throws on a second concurrent call. A session handles one request at a time. Guard on an isResponding flag, or create separate sessions for parallel work.

Output feels generic or hallucinated. This is a compact 3B-class model, not a frontier model. Keep tasks focused, lower the temperature, and explicitly instruct it to only use information present in the input.

Next Steps

  • Explore @Generable enums with associated values to model richer decision trees.
  • Combine tools with WeatherKit, EventKit, or HealthKit to ground generation in real device data.
  • Add a fallback path to a cloud model (Claude or Gemini) for tasks that exceed the on-device model's reach, keeping the private path as the default.
  • Read our related guides: Building AI agents with the Claude Agent SDK and Chrome's Built-in AI Prompt API with Gemini Nano for on-device AI on the web.

Conclusion

You built a complete, private, on-device AI feature in Swift without a single API key. You checked model availability, generated plain text, extracted strongly-typed data with @Generable, streamed results into SwiftUI, called your own Swift code through a tool, and tuned the model for reliability. The Foundation Models framework turns on-device AI into an ordinary Swift API — typed, fast, free, and private by default. That combination makes it the natural first choice for a large class of app features that used to require a backend and a bill.

● Tags
#swift#apple#on-device-ai#ios#foundation-models#intermediate#28 min read
● Share
● A question?

Talk to a Noqta agent about this article.

AI Bot
AI Bot
Author · noqta
Follow ↗

● Read next

MCP‑Governed Agentic Automation: How to Ship AI Agents Safely in 2026
● Tutorial

MCP‑Governed Agentic Automation: How to Ship AI Agents Safely in 2026

Feb 3, 2026
Accelerate Your App Success: Building and Running with Firebase
● Tutorial

Accelerate Your App Success: Building and Running with Firebase

May 23, 2024
AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces
● Tutorial

AI Chatbot Integration Guide: Build Intelligent Conversational Interfaces

Jan 25, 2026
Noqta
Terms and Conditions · Privacy Policy
Services
  • AI Automation
  • AI Agents
  • CX Automation
  • Vibe Coding
  • Project Management
  • Quality Assurance
  • Web Development
  • API Integration
  • Business Applications
  • Maintenance
  • Low-Code/No-Code
Links
  • About Us
  • How It Works?
  • News
  • Tutorials
  • Blog
  • Contact
  • FAQ
  • Resources
Regions
  • Saudi Arabia
  • UAE
  • Qatar
  • Bahrain
  • Oman
  • Libya
  • Tunisia
  • Algeria
  • Morocco
Company
  • Noqta, Tunisia, Tunis, phone +216 40 385 594
© Noqta. All rights reserved.