What it took to build a finance app on iOS 26's Foundation Models
The on-device model is real, it's free, and it will lie to you with total confidence. Here's the architecture that survived contact with it.
Apple shipped an on-device language model with iOS 26. No API key, no network call, no per-token bill, no rate limit you can see. For an app like mine — a budgeting app whose entire premise is your data never leaves the phone — that's not a nice-to-have. It's the only way the "smart" features could exist at all.
I built The Smart Budget on it. This is the honest report: what the model is genuinely good at, the one thing it did that nearly killed the feature, and the architecture I landed on after learning the hard way. All the code here is from the shipped app.
The promise vs. the 3B reality
The WWDC framing is intoxicating: build LLM features that run locally, ship them, done. And a lot of that is true. But the on-device model in iOS 26 is a ~3-billion-parameter model. It is not GPT-4 in your pocket. Treating it like one is how you ship something that embarrasses you.
Here's the split I found after a few weeks:
What the 3B model is genuinely good at: one-shot structured extraction. Give it a short, tightly-scoped task — "read this sentence and pick one of these five intents" — with a schema that constrains the output, and it's excellent. Fast, reliable, and it stays inside the lines you draw.
What it is bad at: anything open-ended. Multi-turn conversation. Free-form prose that also has to be factually precise. The moment you let it write sentences about numbers, it will invent numbers-adjacent details with a completely straight face.
The whole architecture falls out of respecting that split.
The pattern that works: understander → deterministic Swift → narrator
The rule I ended up with: let the model understand language, and never let it do math or state facts. Concretely, every intelligent query in the app runs as three stages, and only the first one touches the model.
// Stage 1: LLM extraction (constrained). Falls back to a tiny keyword
// pass automatically if FM is unavailable / errors.
let question = await QuerySpecExtractor.extract(
from: trimmed,
previous: conversationContext?.lastQuestion,
knownCategoryNames: knownCategoryNames,
knownMerchantsByFrequency: knownMerchantNames
)
// Stage 2: deterministic query.
let answer = await FinanceQueries.answer(question, controller: controller)
// Stage 3: deterministic prose render.
let reply = FinanceNarrator.compose(answer: answer, userQuestion: trimmed)
Stage 1 turns "what did I spend on dining last month?" into a typed FinanceQuestion — an enum intent, a period, an optional merchant or category. Stage 2 is plain Core Data. Stage 3 formats the result. Notice what stages 2 and 3 have in common: no model. The header on the query layer says it out loud:
/// `context.perform`. NO LLM involvement; no `Tool` protocol; no orchestration.
The extraction schema — and the trick that makes it reliable
Stage 1 uses DynamicGenerationSchema, which lets you build the output schema at runtime from the user's own data. That runtime part matters more than it looks. The schema is assembled per question, and — this is the trick — a property only exists in the schema when a signal for it appears in the query:
var properties: [DynamicGenerationSchema.Property] = [
.init(name: "intent", schema: intentSchema),
.init(name: "period", schema: periodSchema),
.init(name: "otherPeriod", schema: otherPeriodSchema, isOptional: true),
.init(name: "limit", schema: limitSchema, isOptional: true),
]
if !merchantNames.isEmpty {
let merchantSchema = DynamicGenerationSchema(
name: "merchant",
description: "Merchant name the user named. Copy EXACTLY from this list.",
anyOf: merchantNames
)
properties.append(.init(name: "merchant", schema: merchantSchema, isOptional: true))
}
If the user didn't name a merchant, the merchant property is not in the schema at all — so the model structurally cannot hallucinate one. You're not asking it nicely to avoid inventing a merchant; you've made inventing one impossible. Constrained decoding turns "please behave" into "you can't misbehave." Then you run it:
let session = LanguageModelSession(model: SystemLanguageModel.default)
let response = try await session.respond(
to: prompt,
schema: schema,
includeSchemaInPrompt: true,
options: GenerationOptions(temperature: 0.1) // we want deterministic-ish picks
)
Temperature 0.1, because this is a classification, not a poem. And even with anyOf constraining the choices, there's a belt-and-braces check afterward that drops any merchant or category the model returned unless a matching token literally appears in the user's text. Trust, then verify, then verify again — it's a finance app.
The pattern that doesn't: letting it narrate
Here's the part I want every developer to read before they ship.
The original Stage 3 was an LLM. I asked the on-device model to "rewrite the deterministic answer in one or two warm sentences," with explicit instructions not to change any numbers. It obeyed the instruction about numbers. And then it invented everything around them. From the actual code comment that now sits where that feature used to be:
/// The previous version asked the on-device Foundation Model to "rewrite the
/// deterministic answer in 1-2 warm sentences," with explicit rules against
/// changing numbers. The model honoured the numbers but invented EVERYTHING
/// ELSE — McDonald's transactions the user never made, merchant names like
/// "Bubble-bro.com" pulled out of thin air, fake per-item amounts. Pure
/// hallucination. On a small on-device model the prompt rules don't stick
/// reliably enough for a finance app where any invented detail destroys trust.
"Bubble-bro.com." A merchant that has never existed, in a summary of someone's real spending, presented as fact. In a budgeting app. There is no clever prompt that makes that acceptable. The number being right doesn't matter if the sentence around it is fiction.
So Stage 3 is now a string template. Not because templates are elegant — because a template cannot lie:
static func compose(answer: FinanceAnswer, userQuestion: String) -> String {
if answer.detail.isEmpty {
return answer.summary
}
return answer.summary + "\n\n" + answer.detail.joined(separator: "\n")
}
(The app does still let the model narrate in a few places — the insight story-cards — but only under the tight outcome guardrails below, and never where it could invent a transaction.)
I also tried the two "obvious" architectures before this one, and both failed on the 3B model. The rationale is preserved in the type header:
/// **Why structured + LLM-extracted:** prior architectures using either
/// (a) `LanguageModelSession`-with-tools or (b) hand-written regex parser
/// both failed. (a) the on-device 3B model can't reliably orchestrate tool
/// calls across multi-turn chat; (b) regex didn't generalise to the long
/// tail of natural phrasings ("on what?", "what are these?", "and dining
/// only?"). The current architecture uses constrained DynamicGenerationSchema
/// for natural-language UNDERSTANDING (front of pipeline) + deterministic
/// Swift for everything else.
Tool-calling is the seductive one, because that's how you'd do it against a frontier model. On-device, the model couldn't reliably orchestrate tools across turns. Extraction-into-a-struct is less glamorous and vastly more robust.
Don't trust the session transcript as memory, either
Multi-turn chat tempts you to lean on the model's conversation transcript for memory. Don't. The context window is small and fills fast. I keep conversation state in plain Swift and re-inline the previous question into each new prompt:
/// **Why not use the model's session transcript for multi-turn memory:**
/// the on-device 4096-token context fills up fast and Apple's developer
/// guidance is that the transcript is an optimisation, not a memory system.
/// We maintain `ConversationContext` ourselves in Swift and inline the
/// previous spec into each turn's prompt — same effect, deterministic memory.
4096 tokens. That's the whole budget. Which is also why the merchant list handed to the extractor is pre-trimmed to ~30 entries — the real list can be 1000+, and dumping it all in would blow the window before the model saw the question.
The guardrails you actually need
Every call to the model goes through one client that wraps the result in a typed outcome, so each caller can decide: retry, fall back to deterministic, or surface the error. The three that earned their keep:
Rate-limit backoff. Apple doesn't publish the limits, so you find them at runtime. Exponential backoff with jitter, then give up so the caller can fall back rather than hang:
case .rateLimited:
guard attemptsLeft > 1 else {
logger.warning("FM rate-limited ... giving up so caller can fall back")
return .rateLimited
}
let baseSeconds = pow(2.0, Double(attemptIndex + 1)) // 2s, 4s, 8s
let jitter = Double.random(in: 0.75...1.25) // ±25%
try? await Task.sleep(nanoseconds: UInt64(baseSeconds * jitter * 1_000_000_000))
return await generateOnce(..., attemptsLeft: attemptsLeft - 1, attemptIndex: attemptIndex + 1)
Context overflow is not retryable. If you blow the 4096-token window, retrying the same prompt does nothing — the caller has to split the work. So it's surfaced as its own distinct outcome, not lumped in with transient failures.
Serialize everything. All model calls funnel through a single actor, so two features can't fire parallel sessions and burn the invisible rate budget twice as fast. And there's one master switch — a Settings toggle ANDed with device availability — that, when off, makes every call return .unavailable so the whole app takes its deterministic path: regex SMS parsing, a deterministic categorizer, keyword chat fallback. The smart layer is an enhancement, never a dependency. The app is fully usable with the model switched off entirely.
The feature I built, finished, and then hid
There's a full conversational "Ask Anything" chat in the codebase — you can ask your money questions in natural language and it answers using the pipeline above. It's implemented. It compiles. And every entry point to it is commented out, tagged ASK-ANYTHING-DEFERRED-IOS27:
// ASK-ANYTHING-DEFERRED-IOS27: chat sheet presentation
// disabled. Implementation kept intact ...
// The 3B model in iOS 26 was unable to reliably extract
// intent + filters even with constrained DynamicGenerationSchema
// — revisit when iOS 27 / a larger on-device model lands.
This was the hardest call of the project. The feature works — most of the time. But "most of the time" is a failing grade when the feature is "ask me anything about your money." Even with all the constraints above, the 3B model's intent extraction wasn't reliable enough that I'd stake the app's trust on it. So it's sitting there, finished, waiting for the model that iOS 27 ships. Shipping the pipeline but hiding the chat was the difference between "clever demo" and "app I'd let my own family use."
What I'd tell myself on day one
- Design for the constrained pattern immediately. Model-as-understander, Swift-as-truth. Don't start with a chatbot and try to bolt on reliability later; you'll rewrite it.
- Never let the model state a fact. Extraction in, deterministic logic and templates out. If the model can emit a merchant name, it eventually will emit a fake one.
- Make hallucination structurally impossible, not merely discouraged. A property that isn't in the schema can't be invented. Prompt rules are suggestions; schemas are walls.
- Treat the smart layer as an enhancement. Ship the deterministic app first, then let the model make it nicer where it can be trusted.
The on-device model is a genuinely great tool once you stop asking it to be something it isn't. It's not a brain you hand the wheel to. It's a very good, very fast intent parser that happens to run for free on the phone — and that, it turns out, is most of what a private finance app actually needs.
The Smart Budget is out now on iPhone (iOS 26, iPhone 16+). Everything above runs on-device; none of your data leaves the phone. If you want the CloudKit side of the story — two iCloud accounts sharing one budget with no server — that's the next post.
— Omar