Apple SDK: small on-device LLMs¶
Overview¶
Run LFM2.5-230M / 350M, Qwen3-0.6B and Gemma 3 1B on device with the TheStage Apple SDK — the shipped packs, no fine-tune. At these sizes a phone can fill an expense form from a photographed receipt, add a calendar event from a chat thread, copy a parcel pickup code, route a share-sheet paste, and answer a live question through a tool. They are not open-domain chat models.
Every reply below was produced by the example project that ships with
the SDK (examples/tutor_small_llms) on Apple Silicon, on the
Neural Engine, with the packs published for SDK 1.4. You can rerun all
of it with one command — see Run it yourself.
API reference: LLM (Language Model).
What you will learn¶
The one design rule that makes small packs reliable: the model copies what is printed or said; the app computes anything derived.
When to decode greedily and when to use the family’s sampling card — and why extraction and chat are different jobs.
How to pass few-shot examples as real chat turns.
Four share-sheet recipes on realistic dumps (a fiscal receipt with OCR noise, a chat thread, four carriers’ pickup notices), each run on all four packs.
Tool calling with
DefaultTools— a live weather and time answer from a spoken question.
Which pack for which job¶
Scores from the runs in this tutorial — cases passed out of cases tried. ✅ all passed · ⚠️ most passed, the miss is named in the recipe · ❌ not this pack.
Job |
LFM2.5-230M |
LFM2.5-350M |
Qwen3-0.6B |
Gemma 3 1B |
|---|---|---|---|---|
Expense JSON from a receipt |
⚠️ 1/2 |
✅ 2/2 |
✅ 2/2 |
⚠️ 1/2 |
Calendar event from a chat thread |
⚠️ 1/2 |
⚠️ 1/2 |
✅ 2/2 |
⚠️ 1/2 |
Parcel pickup code, 4 carriers |
❌ 0/4 |
⚠️ 3/4 |
⚠️ 3/4 |
✅ 4/4 |
Share-sheet router |
❌ |
✅ |
✅ |
❌ |
Tool call for a live fact |
✅ 3/3 |
✅ 3/3 |
✅ 3/3 |
no tools |
Open-domain Q&A, math, code |
❌ |
❌ |
❌ |
❌ |
Qwen3-0.6B is the safe default for every job here. Gemma 3 1B has no
tool support — passing tools: throws. Keep Qwen thinking off
(enable_thinking = false) for all of these.
What you use from the SDK¶
SDK piece |
Role in this tutorial |
|---|---|
|
|
|
The on-device model. One instance per pack. |
|
|
|
Greedy for extraction, the vendor card for tools. |
|
Tool calling: schema + |
|
|
What you implement in the app¶
You build |
Why |
|---|---|
One-job system prompts |
Schema or labels in |
A normaliser |
Turns what the model copied ( |
JSON / label validation |
Parse, allow-list, fall back. |
Optional router |
Cheap classify first, then extract. |
Where it is useful¶
Anywhere the input is already on the device — share sheet, keyboard, receipt photo OCR, mail, notes — and the value is privacy and latency, not world knowledge.
The one rule. The answer must be contained in the input, or fetched by a tool you registered. Transform, classify, extract, route — never recall. Ask “who won the 2010 World Cup” and a 230M–1B pack will guess.
The second rule, which this tutorial exists to teach. Ask the model
to copy, not to compute. A 0.6B model asked to turn
14/O8/2026 into 2026-08-14 fails on every pack; asked to copy
the printed string it succeeds on every pack, and ten lines of Swift
fix the OCR and parse the date. Same for “tomorrow 18:30”: copy the
words, resolve them against today in the app.
Requirement |
Detail |
|---|---|
OS |
iOS / iPadOS 18+, Apple Silicon macOS 15+ |
Hardware |
Physical Apple Silicon. The Simulator is not supported. |
SDK |
Apple SDK 1.4.0+ |
Models |
|
Auth |
API token from app.thestage.ai |
Component-by-component build¶
Load a pack¶
import TheStageSDK
try await TheStageAI.shared.initialize(api_token: token)
let llm = try await TSLLM(
engines_path: "TheStageAI/Qwen3-0.6B",
max_context_size: 2048
)
let engine = LLMChatEngine(llm: llm)
Greedy for extraction, the vendor card for chat¶
Two different jobs, two configs.
Extraction (receipt, calendar, pickup code, router) wants the one right answer, the same every time. Decode greedily: temperature 0, no top-k / top-p filtering. This is what every recipe table below was produced with.
func greedy_config(_ llm: TSLLM, max_new_tokens: Int) -> LLMGenerationConfig {
var config = llm.generation_defaults
config.temperature = 0
config.top_k = 0
config.top_p = 1
config.min_p = 0
config.repetition_penalty = 1.0
config.max_new_tokens = max_new_tokens
config.enable_thinking = false
return config
}
Chat and tools want natural phrasing. Use the sampling card each
vendor publishes for its model — they differ, and a pack’s
generation_defaults is not always the vendor’s card.
Family |
temp |
top_k |
top_p |
min_p |
rep. penalty |
|---|---|---|---|---|---|
LFM2.5 |
0.1 |
50 |
1.0 |
0.15 |
1.05 |
Qwen3 (non-thinking) |
0.7 |
20 |
0.8 |
0 |
1.0 |
Gemma 3 |
1.0 |
64 |
0.95 |
0 |
1.0 |
enum PackFamily { case lfm, qwen, gemma }
func vendor_config(_ llm: TSLLM, family: PackFamily) -> LLMGenerationConfig {
var config = llm.generation_defaults
config.enable_thinking = false
config.max_new_tokens = 256
switch family {
case .lfm:
config.temperature = 0.1; config.top_k = 50
config.top_p = 1.0; config.min_p = 0.15
config.repetition_penalty = 1.05
case .qwen:
config.temperature = 0.7; config.top_k = 20
config.top_p = 0.8; config.min_p = 0
config.repetition_penalty = 1.0
case .gemma:
config.temperature = 1.0; config.top_k = 64
config.top_p = 0.95; config.min_p = 0
config.repetition_penalty = 1.0
}
return config
}
Few-shot as chat turns¶
The pack’s chat template already tags roles. Put the worked example
in messages as a user / assistant pair, then the live input as
the last user turn. Do not paste the example into the system prompt —
the model then treats it as instructions, not as a prior reply.
let result = try await engine.infer(
messages: [
.user(example_input),
.assistant(example_output),
.user(live_input),
],
system_prompt: system,
tools: [],
config: greedy_config(llm, max_new_tokens: 128)
)
// result.text is the reply; result.stop_reason == "max_new_tokens" means it was cut
The normaliser¶
The app-side half of every recipe. The model copies; this turns the copy into a value. It is plain Swift with unit checks — no model involved, so it never surprises you.
/// OCR on fiscal tickets swaps letters for digits inside numbers.
private func repair_ocr_digits(_ text: String) -> String {
var out = ""
for ch in text {
switch ch {
case "O", "o", "Q", "D": out.append("0")
case "l", "I", "|": out.append("1")
case "S": out.append("5")
case "B": out.append("8")
default: out.append(ch)
}
}
return out
}
/// `14/O8/2026`, `03.11.25`, `02-09-2026` → a day. Day-first is a locale
/// fact the app knows; the model does not have to infer it.
func normalize_printed_date(_ printed: String) -> DateComponents? {
let day_field = repair_ocr_digits(printed)
.split(separator: " ").first.map(String.init) ?? ""
let parts = day_field.split(whereSeparator: { "/.-".contains($0) }).map(String.init)
guard parts.count == 3,
let day = Int(parts[0]), let month = Int(parts[1]), var year = Int(parts[2])
else { return nil }
if parts[2].count == 2 { year += 2000 }
guard (1...31).contains(day), (1...12).contains(month), (2000...2100).contains(year)
else { return nil }
var c = DateComponents(); c.year = year; c.month = month; c.day = day
let cal = Calendar(identifier: .gregorian)
guard let d = cal.date(from: c), cal.component(.day, from: d) == day else { return nil }
return c
}
/// Drop the `[dd/mm/yyyy, HH:mm:ss] ` prefixes of a chat export so the
/// model never mistakes a send time for the meeting time.
func strip_export_prefixes(_ export: String) -> String {
export.split(separator: "\n", omittingEmptySubsequences: false).map { line -> String in
var s = Substring(line)
if s.hasPrefix("["), let close = s.firstIndex(of: "]") {
s = s[s.index(after: close)...]
while s.hasPrefix(" ") { s = s.dropFirst() }
}
return String(s)
}.joined(separator: "\n")
}
/// `14/08`, `tomorrow`, `in 3 days`, `friday` + `18:30` → a concrete Date.
/// Returns nil for any phrase it does not know — the app then shows the
/// copied words and asks the user to confirm. It never guesses.
func resolve_said_datetime(day_said: String, start_said: String,
today: Date, calendar: Calendar) -> Date? {
let t = start_said.split(separator: ":").map(String.init)
guard t.count == 2, let hour = Int(t[0]), let minute = Int(t[1]) else { return nil }
let key = day_said.lowercased().trimmingCharacters(in: .whitespaces)
var day: Date?
if key == "today" { day = calendar.startOfDay(for: today) }
else if key == "tomorrow" {
day = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: today))
} else if let rel = relative_days(key) { // "in 3 days", "in 2 weeks"
day = calendar.date(byAdding: rel.unit, value: rel.count,
to: calendar.startOfDay(for: today))
} else if let weekday = weekday_index(key) {
var c = DateComponents(); c.weekday = weekday
day = calendar.nextDate(after: calendar.startOfDay(for: today).addingTimeInterval(-1),
matching: c, matchingPolicy: .nextTime)
} else {
let p = key.split(whereSeparator: { "/.-".contains($0) }).map(String.init)
if p.count >= 2, let d = Int(p[0]), let m = Int(p[1]) {
var c = DateComponents(); c.day = d; c.month = m
c.year = p.count > 2 ? Int(p[2]).map { $0 < 100 ? $0 + 2000 : $0 }
: calendar.component(.year, from: today)
day = calendar.date(from: c)
}
}
guard let base = day else { return nil }
return calendar.date(bySettingHour: hour, minute: minute, second: 0, of: base)
}
weekday_index maps monday / mon … to 1…7;
relative_days parses in N days | weeks | months. The full file,
with the check suite, is in the example project.
Where the model’s job ends. The model’s contribution is the hard,
fuzzy part: finding the day phrase and the time in four messy bubbles,
and noticing that “18:00?” was revised to “18:30”. Turning “tomorrow”
into a date is arithmetic, and the app owns arithmetic. The vocabulary
above is deliberately small and English-only — “next Friday”, “day
after tomorrow”, “domani” return nil — and nil means show the
copied words and ask, never guess. The ✅ marks in the calendar
table mean the model copied the right phrase and the right time; the
ISO stamps are what the app made of them.
Expense JSON from a receipt¶
Camera OCR of an Italian documento commerciale. The OCR noise is
real: Cappuccin0, 14/O8/2026. The model copies the printed
date character for character; normalize_printed_date turns
it into a day.
let system = """
Extract one expense record from an Italian documento commerciale \
(Agenzia delle Entrate RT / Fatture e Corrispettivi layout). Camera \
OCR is noisy: 0/O swaps, extra headers, VAT lines, card last-4, \
document numbers.
Reply with this JSON object only — no markdown, no extra keys:
{"merchant": string, "total": number, "currency": "EUR", "date_printed": string}
merchant — the shop's legal or trade name (the company line, often \
above DOCUMENTO COMMERCIALE). Not P.I. / P.IVA, not DOC, not the \
street, not a line-item name, not the card last-4.
total — the amount actually paid for the purchase. Dot decimal. Not \
di cui IVA, not one line item, not the cash tendered, not the change given.
currency — EUR on Italian fiscal tickets.
date_printed — copy the date exactly as printed on the ticket, \
character for character. Do not reformat it, do not fix OCR, do not \
guess a year.
"""
let example_input = """
AP0THEKE AM BAHNH0F
LINIENSTR. 4O BERLIN
Ibuprofen 400mg N20 8,49
SUMME EUR 8,49
EC ****8812
03.11.25 17:22
BON-NR 004821
"""
let example_output =
"{\"merchant\":\"APOTHEKE AM BAHNHOF\",\"total\":8.49,\"currency\":\"EUR\",\"date_printed\":\"03.11.25\"}"
let ticket = """
STARBUCKS COFFEE ITALY S.R.L.
ROMA TERMINI - VIA GIOBERTI
P.I. 01234560966
DOCUMENTO COMMERCIALE
di vendita o prestazione
DESCRIZIONE IVA Prezzo(€)
Cappuccin0 2 10% 7,00
Cornetto 1 10% 1,80
Subtotale 8,80
TOTALE COMPLESSIVO 8,80
di cui IVA 0,80
Pagamento elettronico 8,80
CARTA ****4521
14/O8/2026 08:41
DOC N. 0001234
"""
let result = try await engine.infer(
messages: [.user(example_input), .assistant(example_output), .user(ticket)],
system_prompt: system, tools: [],
config: greedy_config(llm, max_new_tokens: 128)
)
struct Expense: Decodable {
let merchant: String; let total: Double; let currency: String; let date_printed: String
}
let expense = try JSONDecoder().decode(Expense.self, from: Data(result.text.utf8))
guard ticket.contains(expense.date_printed), // it must be a copy
let day = normalize_printed_date(expense.date_printed) // 2026-08-14
else { throw ExtractionError.notFoundInInput }
Two tickets: the Starbucks one above (want 8.80, 14/O8/2026)
and a tobacconist’s with a cash-and-change layout (want 7.70,
02-09-2026).
OK |
Pack |
Ticket 1 (Starbucks) |
Ticket 2 (Tabaccheria) |
|---|---|---|---|
⚠️ |
LFM2.5-230M |
✅ copies |
❌ right values, but extra keys — decode fails |
✅ |
LFM2.5-350M |
✅ copies |
✅ copies |
✅ |
Qwen3-0.6B |
✅ copies |
✅ copies |
⚠️ |
Gemma 3 1B |
❌ writes |
✅ copies |
The LFM and Qwen packs copy the printed date faithfully — OCR O
included — and the app fixes it; Gemma “helpfully” corrects the OCR,
which the verbatim guard has to reject (relax the guard to compare
after repair_ocr_digits if you want to accept that). Asked instead for YYYY-MM-DD, no pack in
this fleet gets the Starbucks date right. For LFM2.5-230M, keep
JSONDecoder strict and treat a decode failure as “ask a bigger
pack”.
Calendar event from a chat thread¶
A WhatsApp export, [dd/mm/yyyy, HH:mm:ss] Sender: body. The app
strips the bubble prefixes first (strip_export_prefixes) so a send
time can never be mistaken for the meeting time, and the model copies
what was said — "14/08", "13:00" — not an ISO datetime.
let system = """
Read a chat thread and report the meeting they agreed on. Do not \
compute dates and do not convert anything.
Reply with this JSON object only — no markdown, no extra keys:
{"title": string, "day_said": string, "start_said": string, \
"end_said": string or null, "location": string or null}
title — short Title Case name: the activity plus who. Not small talk \
from earlier lines. Not a sentence.
day_said — copy the day exactly as the thread says it: "14/08", \
"tomorrow", "friday". Do not turn it into a date.
start_said — the agreed start time as they wrote it, 24-hour clock. \
If they revised it, use the time they settled on.
end_said — the time someone said they have to leave, or null if nobody said.
location — the place they named, or null.
"""
let example_input = """
Ana Kovac: coffee tue 19/08 10:30 at the station? i leave 11:00
You: yes
"""
let example_output =
"{\"title\":\"Coffee With Ana\",\"day_said\":\"19/08\",\"start_said\":\"10:30\",\"end_said\":\"11:00\",\"location\":\"the station\"}"
let export = """
[13/08/2026, 12:04:11] Marco Rossi: yo u around fri
[13/08/2026, 12:05:03] You: yeah till sat
[13/08/2026, 12:07:44] Marco Rossi: lunch 14/08 13:00 at the termini place? i gotta run 14:30 so dont be late
[13/08/2026, 12:08:02] You: ok see you there
"""
let result = try await engine.infer(
messages: [.user(example_input), .assistant(example_output),
.user(strip_export_prefixes(export))],
system_prompt: system, tools: [],
config: greedy_config(llm, max_new_tokens: 128)
)
struct Meeting: Decodable {
let title: String; let day_said: String; let start_said: String
let end_said: String?; let location: String?
}
let meeting = try JSONDecoder().decode(Meeting.self, from: Data(result.text.utf8))
guard let start = resolve_said_datetime(day_said: meeting.day_said,
start_said: meeting.start_said,
today: today, calendar: calendar)
else {
// Unknown phrase: show "\(meeting.day_said) \(meeting.start_said)" and ask
return
}
// start == 2026-08-14 13:00
Two threads: the export above (want 14/08, 13:00, 14:30)
and a harder one with no few-shot where the day and the time are
in different bubbles and the time is revised — “climbing gym
tomorrow?” … “18:00?” … “make it 18:30” — using a prompt that asks
for one "when" string (tomorrow 18:30).
OK |
Pack |
Thread 1 (export, few-shot) |
Thread 2 (split bubbles, no few-shot) |
|---|---|---|---|
⚠️ |
LFM2.5-230M |
✅ |
❌ tool-call markup, not JSON |
⚠️ |
LFM2.5-350M |
✅ same → 2026-08-14 13:00 |
❌ |
✅ |
Qwen3-0.6B |
✅ same → 2026-08-14 13:00 |
✅ |
⚠️ |
Gemma 3 1B |
✅ same → 2026-08-14 13:00 |
❌ fenced JSON; keeps |
With the prefixes stripped and copy-only fields, every pack gets the straightforward thread. Only Qwen3 glues a day from one bubble to a revised time from another without an example; give the LFM packs a few-shot for that shape or ask the user to confirm.
Parcel pickup code¶
Share the carrier’s SMS, get the code and nothing else. One prompt, one German few-shot, four carriers the model has not seen: Amazon Hub (EN), InPost (PL), Royal Mail (EN, a 5-digit PIN), Bring (NO, a hyphenated code).
let system = """
Read a parcel pickup notification and return the pickup code only. \
One token — no sentence, no quotes.
The pickup code is the short number the recipient types at the locker \
or shows at the counter. It is not the tracking or consignment number, \
not the order number, not the opening hours, not a phone number. Copy \
it exactly, including any hyphen.
"""
let example_input = "Ihre Sendung 00340434123456789016 liegt in der DHL Packstation 139, "
+ "Berlin Hbf. Ihr Abholcode: 4482. Geöffnet 06:00-22:00."
let example_output = "4482"
let sms = """
Your package with 1 item is ready to be picked up from Amazon Hub Locker - Roma Termini (Via Giolitti).
Your pickup code is 482917
Use this code to pick up your package. Locker hours: 06:00-23:00.
Order #204-8471932-5510237
"""
let result = try await engine.infer(
messages: [.user(example_input), .assistant(example_output), .user(sms)],
system_prompt: system, tools: [],
config: greedy_config(llm, max_new_tokens: 16)
)
let code = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard sms.contains(code) else { throw ExtractionError.notFoundInInput } // never trust a code that is not in the input
OK |
Pack |
Amazon |
InPost |
Royal Mail |
Bring |
|---|---|---|---|---|---|
❌ |
LFM2.5-230M |
❌ |
❌ sentence |
❌ sentence |
❌ code + words |
⚠️ |
LFM2.5-350M |
✅ |
✅ |
✅ |
❌ |
⚠️ |
Qwen3-0.6B |
✅ |
✅ |
✅ |
❌ |
✅ |
Gemma 3 1B |
✅ |
✅ |
✅ |
✅ |
The 230M pack parrots the few-shot; use 350M, Qwen3 or Gemma. The
hyphenated Norwegian code is the one shape 350M and Qwen3 mangle —
only Gemma keeps the hyphen — and the sms.contains guard catches
it, so you show the SMS instead of a wrong code.
Tool calling — a live fact¶
The one job where the answer is not in the input: current weather,
current time. Register tools; the SDK teaches the model the pack’s
call format, runs execute, and continues the reply on the same
stream. This is the chat job — use the vendor card, not greedy.
The built-in DefaultTools.get_weather and get_local_time work
as-is. What improves the hit rate is a fuller description of when
to use the tool:
var weather = DefaultTools.get_weather
weather.definition.description = """
Current outdoor conditions and air temperature in °C at one named \
place. This is live data: use it whenever a correct answer depends \
on what the weather is like there now. Not historical or multi-day \
forecast data. Argument: the place the user named, in the form they \
said it. Returns a short plain-text summary to paraphrase; do not \
read it out verbatim.
"""
let tools = [weather, DefaultTools.get_local_time, DefaultTools.web_search]
for ask in ["What's the weather in Paris right now?",
"What time is it in Tokyo?",
"Thanks, that's all for now."] {
for await event in try llm.infer_stream(
prompt: ask,
tools: tools,
system_prompt: DefaultTools.voice_system_prompt,
config: vendor_config(llm, family: .qwen)
) {
switch event {
case .text_delta(let text): print(text, terminator: "") // show or speak
case .tool_call(let call): print("[tool_call] \(call.name)")
default: break
}
}
}
Expected: get_weather then a spoken line with the temperature;
get_local_time then the time; no tool on the thank-you.
OK |
Pack |
Weather in Paris |
Time in Tokyo |
“Thanks, that’s all” |
|---|---|---|---|---|
✅ |
LFM2.5-230M |
✅ call, then 25.5 °C |
✅ call, then 20:26 |
✅ no tool |
✅ |
LFM2.5-350M |
✅ call, then 25.5 °C |
✅ call, then 20:25 |
✅ no tool |
✅ |
Qwen3-0.6B |
✅ call, then 25.5 °C |
✅ call, then 20:27 |
✅ no tool |
— |
Gemma 3 1B |
no tools |
no tools |
— |
All three tool-capable packs call the right tool on a direct ask and stay quiet on small talk. The LFM packs sometimes restate the year in the spoken time (a “2023” or “2024” for 2026) — read the year from the tool result in the app if you show it.
Run it yourself¶
The example project reproduces every table above:
git clone https://github.com/TheStageAI/AppleSDK.git
cd AppleSDK/examples/tutor_small_llms
export TS_API_TOKEN=th_…
PACK=qwen06 GUIDE=1,2,3,4,5 swift run -c release
# PACK: lfm230 | lfm350 | qwen06 | gemma1b GUIDE: any subset of 1..5
Guide 1 is the receipt, 2 the calendar, 3 the pickup code, 4 the router, 5 the tools. First run downloads the pack; later runs start from cache.
Results¶
Copy, don’t compute. Ask for the printed date, the day as said, the code as written. Normalise in Swift. This took the receipt date from 0/4 packs to 4/4.
Greedy for extraction, vendor card for chat. Extraction tables above are greedy and therefore repeatable; the tool answers use each family’s sampling card.
Few-shots are chat turns, never text in the system prompt.
Guard every copy:
input.contains(value)before you trust a code or a date; strictJSONDecoder; allow-list labels.Qwen3-0.6B is the safe default across all five jobs; LFM2.5-350M is close and faster; LFM2.5-230M is for the simplest shapes only; Gemma 3 1B cannot call tools.
Further reading
LLM (Language Model) —
TSLLM,LLMChatEngine, tools,LLMStreamEvent, samplingVoice Agent — the same tools inside a voice assistant
TheStage Apple SDK — install, token, examples
Hugging Face packs: LFM2.5-230M, LFM2.5-350M, Qwen3-0.6B, gemma-3-1b-it