Add Tool Calling To A Foundation Models Session
Foundation Models sessions can do more than answer from the text you put in a prompt. With tool calling, the model can decide to call Swift code you provide, receive the result, and use that result in its final answer.
That sounds like a place where a SwiftUI view could get messy fast. A button calls the model, the model calls a database, the database updates the view, the view passes a new blob back to the model, and suddenly the feature is impossible to reason about.
The cleaner version is boring in the right way: keep the tool small, keep the data access behind a normal Swift type, validate the tool arguments, and let the view model own the session.
The examples below use the Xcode 26.5 SDK signature for Tool. In this SDK, Tool is generic over Arguments and Output: arguments must be convertible from generated content, and output only needs to conform to PromptRepresentable. A String is a perfectly good tool result for a small read-only lookup.
Create a small data source
For this example, imagine a reading-list app where the user can ask questions about saved articles. The model should not invent what the user has saved, so we give it a tool that searches the app’s local index.
import Foundation
struct SavedArticle: Identifiable, Sendable {
let id: UUID
var title: String
var summary: String
var tags: [String]
var url: URL
}
actor ArticleIndex {
private var articles: [SavedArticle] = []
func replaceArticles(_ newArticles: [SavedArticle]) {
articles = newArticles
}
func search(query: String, limit: Int) -> [SavedArticle] {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else { return [] }
return Array(
articles
.filter { article in
article.title.localizedCaseInsensitiveContains(trimmedQuery) ||
article.summary.localizedCaseInsensitiveContains(trimmedQuery) ||
article.tags.contains {
$0.localizedCaseInsensitiveContains(trimmedQuery)
}
}
.prefix(limit)
)
}
}
The actor is not AI-specific. It is just the app boundary around a mutable index. That matters because Foundation Models can invoke a tool more than once during a request, and Tool.call(arguments:) is an async concurrent boundary. If the tool reads or writes shared state, protect that state the same way you would in any other concurrent Swift code.
Define the tool
A static tool usually has a name, a description, an argument type, and a call(arguments:) method. The argument type is a great place for @Generable and @Guide, because the model generates these arguments before your Swift code runs. When Arguments conforms to Generable, the SDK can derive the tool’s parameters schema.
import Foundation
import FoundationModels
struct FindSavedArticlesTool: Tool {
let name = "findSavedArticles"
let description = "Find saved articles in the user's reading list."
let index: ArticleIndex
@Generable
struct Arguments {
@Guide(description: "A short search phrase from the user's question")
var query: String
@Guide(description: "The maximum number of articles to return", .range(1...5))
var limit: Int
}
func call(arguments: Arguments) async throws -> String {
let query = arguments.query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
return "No saved articles matched an empty query."
}
let limit = min(max(arguments.limit, 1), 5)
let matches = await index.search(query: query, limit: limit)
guard !matches.isEmpty else {
return "No saved articles matched \"\(query)\"."
}
return matches
.map { article in
"""
Title: \(article.title)
Summary: \(article.summary)
Tags: \(article.tags.joined(separator: ", "))
URL: \(article.url.absoluteString)
"""
}
.joined(separator: "\n\n")
}
}
The @Guide(.range(1...5)) helps the model produce a valid limit, but I still clamp it in call(arguments:). That is the right split of responsibility. Guided generation helps the model stay within the argument schema. Your tool still owns app rules, empty strings, permissions, rate limits, and anything that could become expensive.
Keep the name and description short. The framework includes tool definitions in the session context so the model can decide when to call them. A paragraph-long tool description costs tokens and usually makes the model harder to steer.
Attach tools to the session
Tools are passed when you create the LanguageModelSession. That means a SwiftUI view should not be assembling tools in its body. Put the session creation in a model method where you can check availability, configure instructions, and handle errors in one place.
import FoundationModels
import Observation
@MainActor
@Observable
final class ArticleAssistantModel {
var question = ""
var answer = ""
var isLoading = false
var errorMessage: String?
private let languageModel = SystemLanguageModel.default
private let index: ArticleIndex
init(index: ArticleIndex) {
self.index = index
}
var canAsk: Bool {
!question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!isLoading &&
isModelAvailable
}
var isModelAvailable: Bool {
if case .available = languageModel.availability {
return true
}
return false
}
func ask() async {
let question = question.trimmingCharacters(in: .whitespacesAndNewlines)
guard !question.isEmpty else { return }
switch languageModel.availability {
case .available:
break
case .unavailable(let reason):
answer = ""
errorMessage = unavailableMessage(for: reason)
return
}
isLoading = true
answer = ""
errorMessage = nil
let session = LanguageModelSession(
tools: [
FindSavedArticlesTool(index: index)
],
instructions: """
Answer questions about the user's saved articles.
Use findSavedArticles when the answer depends on the reading list.
If the tool returns no matches, say that clearly.
Keep the answer to two short paragraphs.
"""
)
do {
let response = try await session.respond(
to: "Question from the user: \(question)",
options: GenerationOptions(
temperature: 0.2,
maximumResponseTokens: 300
)
)
answer = response.content
} catch LanguageModelSession.GenerationError.unsupportedLanguageOrLocale {
errorMessage = "The model cannot respond in this language or locale."
} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
errorMessage = "This question and its tool context are too large."
} catch is LanguageModelSession.ToolCallError {
errorMessage = "The reading list could not be searched."
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
private func unavailableMessage(
for reason: SystemLanguageModel.Availability.UnavailableReason
) -> String {
switch reason {
case .deviceNotEligible:
"This device does not support Apple Intelligence."
case .appleIntelligenceNotEnabled:
"Apple Intelligence is not enabled."
case .modelNotReady:
"The language model is not ready yet."
@unknown default:
"The language model is not available right now."
}
}
}
The instructions are doing important work. They tell the model when the tool is relevant and what to do when the tool cannot find anything. Keep user input in the prompt, not interpolated into the instructions. Instructions are developer-authored policy for the session; prompts are the current user request.
Use it from SwiftUI
The view stays pleasantly normal:
import SwiftUI
struct ArticleAssistantView: View {
@State private var model: ArticleAssistantModel
init(index: ArticleIndex) {
_model = State(initialValue: ArticleAssistantModel(index: index))
}
var body: some View {
Form {
TextField("Ask about saved articles", text: $model.question)
Button("Ask") {
Task {
await model.ask()
}
}
.disabled(!model.canAsk)
if model.isLoading {
ProgressView()
}
if let errorMessage = model.errorMessage {
Text(errorMessage)
.foregroundStyle(.secondary)
}
if !model.answer.isEmpty {
Text(model.answer)
}
}
.navigationTitle("Reading List")
}
}
There is no database logic in the view and no Foundation Models logic in the row rendering. The view collects input, starts a task, and displays state. The model owns the session. The tool owns one narrow capability.
When a tool is the wrong move
Tool calling is useful when the model needs to decide whether extra information would help, or when it needs to choose arguments based on natural language. It is not better than ordinary Swift for deterministic work.
If every request needs the same three records, fetch those records yourself and put them in the prompt. If the user is about to delete data, make the tool prepare a proposed action and ask the user to confirm before performing the side effect. If the model only needs a value you already have on screen, pass the value directly.
I like starting with read-only tools. Search a local index, summarize a selected document, fetch app metadata, or look up a user-approved record. Once that feels solid, side-effecting tools are easier to review because the tool boundary is already clear.
Tool definitions and tool outputs both count against the session context. Return the smallest useful result, not a whole table dump. A tool that returns five concise article summaries will usually produce a better answer than one that returns fifty full documents and hopes the model sorts it out.
Further reading: