Hello! 👋
Today, I’m happy to say we have Ildar Zalyalov as our guest author. He’s a Lead Software Engineer at H&M Group with more than 11 years of professional experience in iOS development.
His journey spans the evolution from Objective-C to Swift, SwiftUI, modern concurrency, and AI-assisted development. Beyond building products, Ildar actively supports the iOS community through teaching and mock interviews, helping developers strengthen both their technical knowledge and interview skills.
P.S. You can check out Ildar’s work on LinkedIn and YouTube.
A schema gives an intent meaning that the system already understands. Instead of exposing an arbitrary action that happens to accept a search string, for example, we can describe it as an actual search operation.
In this post, we’ll look at why system schemas are useful, and implement an in-app search intent that can open an app directly on its own search results.
Suppose we’re building a recipe app and want to expose its search functionality to Siri.
We could start with a custom App Intent:
import AppIntents
struct SearchRecipesIntent: AppIntent {
static let title: LocalizedStringResource = "Search Recipes"
@Parameter(title: "Query")
var query: String
func perform() async throws -> some IntentResult {
// Pass the query to the app.
return .result()
}
}
This is a valid intent. The system sees an action called Search Recipes with a string parameter.
But search isn’t really specific to our app. It’s an operation Siri already understands.
App Schemas describe common actions using structures known to the system. Apple provides schemas across domains such as messages, mail, photos, media, and system functionality.
When one accurately represents what our app does, it’s usually better to adopt that schema than to create another custom representation of the same operation.
For in-app search, we can use the system search schema:
import AppIntents
@available(iOS 27, *)
@AppIntent(schema: .system.searchInApp)
struct SearchRecipesIntent: ShowInAppSearchResultsIntent {
static var supportedModes: IntentModes { .foreground }
static var allowedExecutionTargets: IntentExecutionTargets {
.main
}
static let searchScopes: [StringSearchScope] = [
.general
]
@Parameter
var criteria: StringSearchCriteria
func perform() async throws -> some IntentResult {
let query = criteria.term
// Open the app's search experience.
return .result()
}
}
The difference is more than syntax.
With the custom intent, query is simply a parameter we defined. With ShowInAppSearchResultsIntent, the intent adopts the system’s search contract. The system provides StringSearchCriteria, and its term property contains the string to search for.
Apple recommends StringSearchCriteria for ordinary text-based searches, together with the .general search scope for general app content.
StringSearchScope.general also tells the system that the app provides general search functionality. Apple notes that this may participate in system-wide universal search behavior, depending on the app and its content.
A search intent is unusual in one important way: performing the action isn’t enough. We want to show something in our interface.
ShowInAppSearchResultsIntent is specifically intended to display search results in the app. Apple also notes that this kind of intent needs to run from the app rather than an App Intents extension.
For that reason, I prefer making the execution requirements explicit:
static var supportedModes: IntentModes {
.foreground
}
static var allowedExecutionTargets: IntentExecutionTargets {
.main
}
ShowInAppSearchResultsIntent already provides a foreground-capable default, so declaring supportedModes isn’t strictly necessary in every implementation.
I still find it useful here because it documents an important property of the intent: its result is an app UI transition, not a background operation.
Restricting the execution target to .main is more significant when App Intents code lives in a shared framework or the application also has App Intents or Widget extensions. It makes sure this particular action executes in the main app process.
StringSearchCriteria.term is a non-optional string, but that doesn’t necessarily mean it contains a useful search query.
A small validation step makes the intent more robust:
static func normalizedTerm(_ term: String) -> String? {
let trimmed = term.trimmingCharacters(
in: .whitespacesAndNewlines
)
return trimmed.isEmpty ? nil : trimmed
}
We can use it from perform():
@MainActor
func perform() async throws -> some IntentResult {
guard let searchTerm = Self.normalizedTerm(criteria.term) else {
throw $criteria.needsValueError(
"What do you want to search for?"
)
}
// Open search for `searchTerm`.
return .result()
}
Throwing needsValueError is more useful than silently doing nothing. It tells the App Intents system that the parameter doesn’t contain enough information to continue and gives it a chance to ask the user for a value.
For a request such as: “Show me pasta recipes in RecipeBox.” the value we eventually care about is just:
"pasta"
The interesting part is what we do with it next.
A small demo could navigate directly from perform().
In a real application, that becomes fragile quickly.
The intent may run while the application is already visible, or it may cause a cold launch. In the second case, perform()can happen before the root view hierarchy is ready to navigate.
Instead of making the intent know about view controllers or SwiftUI navigation, we can hand the action over to application-level routing code.
Here’s a small version of that pattern:
import Observation
enum Destination: Hashable {
case search(String)
}
@MainActor
@Observable
final class AppNavigation {
var path: [Destination] = []
private var pendingSearch: String?
private var isUIReady = false
func enqueueSearch(_ query: String) {
pendingSearch = query
replayPendingSearchIfPossible()
}
func markUIReady() {
isUIReady = true
replayPendingSearchIfPossible()
}
private func replayPendingSearchIfPossible() {
guard
isUIReady,
let query = pendingSearch
else {
return
}
pendingSearch = nil
path.append(.search(query))
}
}
The distinction between enqueueSearch() and actually changing the navigation path is intentional.
If the UI already exists, the queued search is handled immediately.
If the app is still launching, the request remains pending until the UI reports that it’s ready.
The intent itself stays small:
import AppIntents
@available(iOS 27, *)
@AppIntent(schema: .system.searchInApp)
struct SearchRecipesIntent: ShowInAppSearchResultsIntent {
static var supportedModes: IntentModes { .foreground }
static var allowedExecutionTargets: IntentExecutionTargets {
.main
}
static let searchScopes: [StringSearchScope] = [
.general
]
@Parameter
var criteria: StringSearchCriteria
@Dependency
private var navigation: AppNavigation
@MainActor
func perform() async throws -> some IntentResult {
guard let searchTerm = Self.normalizedTerm(criteria.term) else {
throw $criteria.needsValueError(
"What do you want to search for?"
)
}
navigation.enqueueSearch(searchTerm)
return .result()
}
static func normalizedTerm(_ term: String) -> String? {
let trimmed = term.trimmingCharacters(
in: .whitespacesAndNewlines
)
return trimmed.isEmpty ? nil : trimmed
}
}
Notice what isn’t here.
The intent doesn’t push a view controller, mutate a tab bar, construct a SwiftUI destination, or perform the search itself.
It only translates a system intent into an application action.
We can register the same navigation object with App Intents and SwiftUI:
import AppIntents
import SwiftUI
@main
struct RecipeBoxApp: App {
@State private var navigation: AppNavigation
init() {
let navigation = AppNavigation()
_navigation = State(initialValue: navigation)
AppDependencyManager.shared.add(
dependency: navigation
)
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(navigation)
}
}
}
And let the root UI announce when navigation is available:
import SwiftUI
struct ContentView: View {
@Environment(AppNavigation.self)
private var navigation
var body: some View {
@Bindable var navigation = navigation
NavigationStack(path: $navigation.path) {
List {
Text("Spaghetti Carbonara")
Text("Pasta Primavera")
Text("Tomato Soup")
Text("Margherita Pizza")
}
.navigationTitle("Recipes")
.navigationDestination(for: Destination.self) { destination in
switch destination {
case .search(let query):
SearchResultsView(query: query)
}
}
}
.task {
navigation.markUIReady()
}
}
}
For a warm launch, isUIReady is already true and enqueueSearch() immediately adds the search destination.
For a cold launch, the intent stores the query first. Once ContentView appears, markUIReady() replays the pending action.
This mirrors a useful pattern in larger applications, where intents, deep links, push notifications, widgets, and other external entry points all feed the same action or navigation coordinator.
If an app already has such an abstraction, the intent should use it rather than introducing a new routing system specifically for Siri.
Our result screen doesn’t need to know where the query came from:
import SwiftUI
struct SearchResultsView: View {
@State private var query: String
private let recipes = [
"Spaghetti Carbonara",
"Pasta Primavera",
"Tomato Soup",
"Margherita Pizza"
]
init(query: String) {
_query = State(initialValue: query)
}
private var results: [String] {
recipes.filter {
$0.localizedCaseInsensitiveContains(query)
}
}
var body: some View {
List(results, id: \.self) { recipe in
Text(recipe)
}
.navigationTitle("Search")
.searchable(
text: $query,
prompt: "Search recipes"
)
}
}
The same screen could have been opened from the app itself, from a deep link, or from Siri.
That’s a useful boundary.
Siri provides the meaning of the external request. The intent translates it into an app action. From that point on, the existing application architecture takes over.
searchInApp doesn’t replace App Entities.
An AppEntity represents an identifiable object from our app. A recipe app could expose individual recipes as RecipeEntity values, allowing Siri and other system experiences to resolve or operate on a particular recipe.
In-app search serves a different purpose.
Instead of requiring the system to resolve one entity, ShowInAppSearchResultsIntent gives the search criteria back to the app and lets the app display its own results.
That works particularly well when the search experience includes server-side content, filters, ranking, recommendations, or presentation logic we already maintain ourselves.
Apple also notes that apps can use this search integration without requiring every searchable entity to be indexed first.
The most useful part of the implementation isn’t the amount of Siri-specific code we added.
It’s how little we needed.
The final intent essentially does three things:
guard let term = Self.normalizedTerm(criteria.term) else {
throw $criteria.needsValueError(
"What do you want to search for?"
)
}
navigation.enqueueSearch(term)
return .result()
The system schema describes what the action means. StringSearchCriteria carries the query. The application’s existing routing and search layers decide what happens next.
This is a good pattern beyond search as well. Before creating a custom App Intent, it’s worth checking whether an App Schema already describes the operation. If it does, we can use the system’s vocabulary and keep the intent itself as a small adapter into the application.
For in-app search, that adapter is enough to take a request from Siri all the way to a native search results screen.