Building a custom DynamicProfileModifier in Foundation Models

Sponsored
Glaze by Raycast
Desktop apps, reimagined by you. Create software for you and your team — it lives on your Mac and connects to your files, tools, and hardware.
Learn More →Dynamic profiles are the headline addition to the Foundation Models framework this year: a session can now switch models, instructions, tools, and generation options at runtime. Most of the attention goes to profiles themselves, but a small protocol next to them deserves a closer look: LanguageModelSession.DynamicProfileModifier.
If you write SwiftUI, you already know this pattern. DynamicProfileModifier is to a profile what ViewModifier is to a view: a reusable bundle of configuration you attach with a single call. It keeps repeated setup in one place, gives it a meaningful name, and hides details that don't belong at every call site.
We'll build a practical one: an economy mode that switches a profile to the on-device model, gives it a smaller view of the conversation, and caps the response. It can preserve a person's daily Private Cloud Compute allowance, reduce spend with a commercial provider, keep a feature working offline, or reduce latency when a larger model isn't necessary. Think Low Power Mode, but for model capacity.
Requirements
- Xcode 27 beta and an iOS/iPadOS/macOS 27 beta.
- A remote
LanguageModelif you want to run the model-switching branch. You can use Private Cloud Compute, Claude, OpenAI, or your own provider.
Private Cloud Compute requires a managed entitlement. Anthropic, OpenAI, and other commercial providers require their own credentials and network connection. The APIs in this post are beta, so check availability and test again with the final SDK and operating system before shipping.
Understanding dynamic profiles
Before iOS 27, a session's instructions, tools, and model were fixed at initialization. Dynamic profiles change that: a LanguageModelSession.DynamicProfile re-evaluates its body before every model request, so the session can switch models, instructions, tools, and generation options as your app's state changes. A Profile binds instructions and tools to configuration modifiers. Here's a basic assistant to start from — we'll grow it into a configurable one later:
import FoundationModels
struct BasicAssistantProfile: LanguageModelSession.DynamicProfile {
var body: some LanguageModelSession.DynamicProfile {
Profile {
Instructions("You are a helpful assistant.")
}
.model(PrivateCloudComputeLanguageModel())
.temperature(0.7)
}
}The session takes the whole thing via a new initializer:
let session = LanguageModelSession(profile: BasicAssistantProfile())The framework ships a set of built-in modifiers — model(_:), temperature(_:), samplingMode(_:), reasoningLevel(_:), maximumResponseTokens(_:), historyTransform(_:), and lifecycle callbacks like onResponse(perform:). These settings tend to travel in groups. An on-device fallback might always use the same model, response limit, and context window; a creative configuration might repeat another trio. Copying those lines between profiles is exactly the problem DynamicProfileModifier solves.
Building the modifier
The protocol has a single requirement — body(content:) — which receives the wrapped profile as Content and returns a new profile with your configuration applied:
struct EconomyModeModifier:
LanguageModelSession.DynamicProfileModifier
{
let isEnabled: Bool
let maximumResponseTokens: Int
let historyEntries: Int
func body(
content: Content
) -> some LanguageModelSession.DynamicProfile {
if isEnabled {
content
// 1
.model(SystemLanguageModel.default)
// 2
.temperature(0.3)
// 3
.maximumResponseTokens(maximumResponseTokens)
// 4
.historyTransform { history in
Array(history.suffix(historyEntries))
}
} else {
content
}
}
}The isEnabled branch is the same trick SwiftUI developers use for conditional view modifiers: when the flag is off, the modifier returns the wrapped profile untouched. That lets the modifier live permanently in a profile's chain while app state decides whether it does anything.
And, just like with ViewModifier, an extension turns it into a nice call site. Parameters keep the defaults convenient without making the abstraction rigid:
extension LanguageModelSession.DynamicProfile {
func economyMode(
isEnabled: Bool = true,
maximumResponseTokens: Int = 250,
historyEntries: Int = 8
) -> some LanguageModelSession.DynamicProfile {
modifier(
EconomyModeModifier(
isEnabled: isEnabled,
maximumResponseTokens: maximumResponseTokens,
historyEntries: historyEntries
)
)
}
}The values 250 and 8 are deliberately small demo defaults, not universal recommendations. Keep both parameters positive and tune them against representative conversations.
Let's unpack what each line buys us:
- Routes the request to the on-device model. Model inference works without a network connection and stays on device. Tools attached to the profile may still access the network, so review them separately.
- Uses a lower temperature to make utility answers more focused and predictable. Temperature changes token selection, not the number of tokens the model produces, so the next setting is what constrains length.
- Protects against unexpectedly verbose responses. If the model reaches the limit before finishing naturally, the framework stops generation without throwing an error. A hard cap can therefore produce an incomplete sentence or malformed structured output. Use it for bounded free-form answers, combine it with instructions that ask for brevity, and choose the value through evaluation rather than guesswork.
- Sends only the last few transcript entries to the model. The on-device context window is smaller than PCC's — you can inspect it at runtime via
SystemLanguageModel.default.contextSize— so a long conversation may not fit. I covered context limits and token counting in Tracking token usage in Foundation Models. The transform is local and lossless with respect to the session: it changes what the model sees for this request, whilesession.transcriptkeeps the complete conversation.
One important simplification: transcript entries aren't the same as conversation turns. Prompts, responses, tool calls, and tool outputs are separate entries, so a blind suffix(8) can split a logical sequence. Our assistant has no tools, which keeps the example readable. In a tool-heavy profile, first remove completed tool interactions as complete units, then apply a rolling window.
Applying the modifier
The payoff comes when a dynamic profile switches configuration based on live app state. That mutable state needs to be shared beyond the profile value copied into the session. Following Apple's WWDC example, both the app and the profile reference the same object:
import Observation
@Observable
final class AssistantState {
var usesEconomyMode = false
}
struct AssistantProfile<RemoteModel: LanguageModel>:
LanguageModelSession.DynamicProfile
{
let state: AssistantState
let remoteModel: RemoteModel
var body: some LanguageModelSession.DynamicProfile {
Profile {
Instructions("You are a helpful assistant.")
}
.economyMode(isEnabled: state.usesEconomyMode)
.model(remoteModel)
.temperature(0.7)
}
}No if/else, no duplicated Profile declaration: the modifier itself branches. The ordering is deliberate — economyMode(isEnabled:) sits closest to the Profile, so when the flag is on, its model and temperature beat the outer .model(remoteModel) and .temperature(0.7). When the flag is off, the modifier is transparent and the outer values apply. The next section explains the precedence rule this relies on.
Create the state and model once, then pass them into the profile. Private Cloud Compute keeps the dependency-free version entirely inside Apple's framework:
let state = AssistantState()
let remoteModel = PrivateCloudComputeLanguageModel()
let session = LanguageModelSession(
profile: AssistantProfile(
state: state,
remoteModel: remoteModel
)
)Because body re-evaluates before every request, changing state.usesEconomyMode affects the next request:
let remoteResponse = try await session.respond(
to: "Explain dynamic profiles in three sentences."
)
print(remoteResponse.content)
state.usesEconomyMode = true
let localResponse = try await session.respond(
to: "Now summarize that in one sentence."
)
print(localResponse.content)The modifier configures what economy mode means; it doesn't decide when to enable it — the isEnabled flag comes from app state. Keep that policy there, where it can react to a person's toggle, Low Power Mode, network reachability, a provider spending limit, or PrivateCloudComputeLanguageModel.quotaUsage. If a remote request fails, only retry errors that the specific provider documents as transient or quota-related — don't turn every model refusal or malformed request into an automatic retry.
The session and global transcript remain the same when the mode changes. Only the active model configuration and its view of history change.
Using Claude or OpenAI instead of PCC
AssistantProfile is generic over LanguageModel, so PCC isn't special. Anthropic's ClaudeForFoundationModels package provides a model you can pass to the same profile:
import ClaudeForFoundationModels
let remoteModel = ClaudeLanguageModel(
name: .sonnet4_6,
auth: .apiKey("YOUR_API_KEY")
)For OpenAI, Apple's Foundation Models Utilities package includes ChatCompletionsLanguageModel, which works with OpenAI-compatible endpoints:
import Foundation
import FoundationModelsUtilities
let remoteModel = ChatCompletionsLanguageModel(
name: "YOUR_MODEL_ID",
url: URL(string: "https://api.openai.com/v1")!,
additionalHeaders: [
"Authorization": "Bearer YOUR_API_KEY"
],
supportsGuidedGeneration: false
)Set supportsGuidedGeneration to true only when the selected provider and model support strict structured outputs.
Both values can replace PrivateCloudComputeLanguageModel() without changing AssistantProfile or LanguageModelSession. I walked through the provider-specific setup in Using Claude with Apple Foundation Models.
Never ship a commercial provider's API key in an app binary. The literal keys above are development placeholders. Use a backend proxy that authenticates the app and attaches the provider credential in production.
Handling modifier precedence
One subtlety our assistant relies on. When the same value appears at multiple levels, precedence runs from highest to lowest:
- matching
GenerationOptionspassed torespond(to:options:)at the call site; - the modifier closest to the
Profiledeclaration; - outer dynamic profile modifiers.
AssistantProfile works because economyMode(isEnabled:) is the modifier closest to the Profile: when the flag is on, its .model and .temperature win every conflict with the outer remote configuration. Reverse the order and economy mode silently loses those conflicts:
Profile {
Instructions("You are a helpful assistant.")
}
.model(remoteModel)
.temperature(0.7)
// Outer now, so remoteModel and 0.7 win even when the flag is on.
.economyMode(isEnabled: state.usesEconomyMode)Note that this reversal doesn't simply turn economy mode off. Precedence only resolves conflicts, and maximumResponseTokens and historyTransform have no competing values in the chain — so they still apply. The result is a worse hybrid: the remote model, but with a trimmed history and a capped response.
In other words, position — not the modifier's name — decides whether economyMode() enforces its settings or merely suggests defaults. Keep it innermost when model selection is a policy requirement.
Call-site GenerationOptions only override matching generation values, such as temperature or maximum response tokens. They don't replace the profile's model, history transform, or lifecycle callbacks.
Lifecycle callbacks also compose differently from value modifiers. If both a profile and an outer custom modifier register onResponse, the framework runs both callbacks instead of choosing one by precedence. That makes custom modifiers useful for cross-cutting behaviors such as logging and metrics, but it also means each callback needs to tolerate the others.
Paying the cache tax
Rewriting history isn't free. Appending to the transcript typically preserves the key-value cache, while removing entries, changing tools, or swapping instructions can invalidate it and increase time-to-first-token. historyTransform returns the same entries until the history grows beyond our limit. After that, every new entry can shift the rolling window and change its prefix — a cache-unfriendly pattern for models that cache that prefix.
Switching between a remote and on-device model prevents one model from reusing the other's cache, but that only explains the cost of the transition. If economy mode stays active for several turns, the sliding window may continue to invalidate the on-device model's cache. A smaller context can still be the right tradeoff, but it isn't automatically free. Measure the real workflow with the upgraded Foundation Models instrument in Xcode 27, which exposes cache usage and time-to-first-token.
Conclusion
DynamicProfileModifier is a small protocol, but it completes the SwiftUI-like composition model of the Foundation Models framework: declarative profiles, composable instructions, and reusable configuration policies. Economy mode is one useful example, and it works with any LanguageModel, not only PCC.
History redaction, quota accounting, and other security- or billing-sensitive policies also fit the abstraction, but the modifier itself is only the wrapper. Their correctness still depends on robust transcript handling, availability checks, and evaluations.
Happy modifying!
