Initializing…

Remind Me Of Stuff

Getting a 270M-parameter model to do multi-step function calling, entirely on an iPhone

1 / 15

What I Was Trying To Build

  • An offline reminder app. User says "remind me to check the tire pressure before Vegas," the model routes that to the right tool with the right args.
  • Multi-step tool calls. One prompt might need: pick a list → wait for the result → create the reminder. The model has to pause, listen, and continue.
  • Real system APIs. The output isn't text on a screen — it's an EventKit event, a calendar entry, a weather fetch, all from Swift.
All of it happens on the phone — no roundtrip to a server
2 / 15

Why Not Just Use Siri / Gemini

What Apple Intelligence Does

Siri + Gemini is genuinely good now — but it routes to Apple's servers for anything non-trivial. That's a privacy tradeoff I wasn't willing to make for my own reminders, and it doesn't work in flight mode.

What I Wanted Instead

A fully offline pipeline. No API keys, no auth, no server bills. The model, the tool registry, and the system APIs all live on the device. Open the app with no signal — it still works.

3 / 15

Why FunctionGemma-270m

270M parameters. That's it.

Rounded up it'll ship to A-series iPhones and fit in memory next to the OS. Bigger models (Llama, Mistral) need 4–7GB and a beefy GPU — not an option on a phone.

Already knows what a tool call looks like

Google pre-trained it on JSON schema/function-calling data. So I didn't have to teach it what a function call is — just how to do my tools, my schemas, my multi-turn pattern.

4 / 15

The Runtime: LiteRT-LM

Loading & Inference

Google ships LiteRT-LM as an XCFramework. I wrapped it in a Swift Actor so concurrent callers don't race the model. Tokenization, the forward pass, the Metal shader — all handled by the framework.

The Tool-Call Handoff

During streaming, if the model emits a tool call I pause generation, route the JSON to my ToolRegistry (which dispatches to WeatherTool, CalendarTool, etc.), run the Swift code, and inject the result back into the conversation.

5 / 15

What the Base Model Actually Did

The first test, in the browser

LiteRT runs via WebGPU, so I prototyped in the browser first — faster iteration than rebuilding in Xcode every time.

  • Prompt: "Remind me to buy milk."
  • What it did wrong: It chatted at me first, then hallucinated a list ID, then stopped mid-token. Two failures that would both crash the Swift decoder.
$ remind me to buy milk // Expected: structured tool call // Actual: { "tool": "I can help with that! Let me create a reminder for you." "params": { "text": "buy milk" "list": "genera // Decoder crash: unexpected conversational token }
6 / 15

Why Fine-Tuning Was Unavoidable

The schemas are mine, not Google's

FunctionGemma knows the idea of tool calls, but it has no clue what CategorizeTool or ReminderTool are. Those are local to my app.

Multi-turn is not the default behavior

The model has to learn: emit a tool call, stop, let Swift inject a result, then resume. Off-the-shelf models just keep babbling.

Chat filler kills the parser

My Swift JSONDecoder expects one format. If the model prefixes it with "Sure! Here's your JSON:" — the app crashes. Every sample in the training set is raw JSON, nothing else.

7 / 15

Why Not Just Few-Shot Prompt It?

Few-shot prompting

You paste 10 examples into the system prompt. The model sees them every inference but its weights never change. It might mimic the format — until you phrase something differently.

Doesn't generalize

Ask "remind me about milk" instead of "remind me to buy milk" and the pattern breaks. The model is pattern-matching, not reasoning.

Burns context window

Every example eats tokens the model could use for reasoning. With 15 tools × 2 examples each, you've burned 1,500+ tokens before the user types a word.

Fine-tuning (LoRA)

103 training examples run through backpropagation. The model's weights actually change. At inference time the prompt is empty and the behavior is still there.

Learns the distribution

After 1,140 gradient updates, the model doesn't just copy examples — it extracts the underlying pattern: "user action → tool call → result → next call."

Zero-shot at runtime

No examples in the prompt. Every token goes to actual reasoning. The fine-tuned behavior is baked into the weights, not pasted into the context.

8 / 15

LoRA — Training on My MacBook

Why not full fine-tune?

Full fine-tuning of 270M params needs real GPUs and a training cluster. LoRA freezes the base model and only trains a couple of low-rank adapter matrices — it fit on 8GB of VRAM on an M-series Mac.

Same math Stable Diffusion uses to learn art styles. The adapters are tiny, and at merge time they fold right back into the base weights.

IN
W
A
W
A
W
OUT
frozen
adapter
frozen
adapter
frozen
Base model frozen — only adapter matrices trained
9 / 15

Synthetic Training Data

I wrote Python generators that emit .jsonl in Google's mobile-actions format — the model's existing instruction vocabulary, but with my tool names and my schemas.

  • 405 single-step samples — simple prompts ("weather in X," "what time is it") mapping to exactly one tool call.
  • 735 multi-step samples — the hard case: categorize, emit one call, wait, then read the injected context and emit the second call. The model had to learn patience.
{ "instruction": "Remind me to buy milk", "output": "[{\"tool\":\"CategorizeTool\",\"params\":{\"text\":\"buy milk\"}}]" } { "instruction": "What's the weather in Tokyo?", "output": "[{\"tool\":\"WeatherTool\",\"params\":{\"location\":\"Tokyo\"}}]" } { "instruction": "Create a shopping list and add eggs", "output": "[{\"tool\":\"CreateList\",\"params\":{\"name\":\"Shopping\"}}]" } { "instruction": "Schedule meeting tomorrow at 2pm", "output": "[{\"tool\":\"CalendarTool\",\"params\":{\"title\":\"Meeting\",\"time\":\"...\"}}]" } { "instruction": "Find Italian restaurants nearby", "output": "[{\"tool\":\"SearchTool\",\"params\":{\"query\":\"Italian restaurants\"}}]" } ... 1,140 total samples
10 / 15

The Pipeline

Train LoRA adapters on the MacBook's Metal GPU, merge them back into FunctionGemma's weights, quantize into a .litertlm bundle, drop it into Xcode.

1
Generate JSONL
2
Train LoRA on Metal
3
Merge Adapters
4
Export .litertlm
5
Bundle in Xcode
11 / 15

Same Prompt, After Fine-Tuning

Back in the browser

I swapped the base weights for the merged/fine-tuned .litertlm. Same browser, same WebGPU harness, same prompt.

  • Prompt: "Remind me to buy milk."
  • What it did: Emitted a clean CategorizeTool call, paused on EOF, consumed the injected result, then fired ReminderTool. Two hops, no filler, no hallucinated fields.
Before (base model) { "tool": "CategorizeTool", "params": { "text": "Sure! Let me help you with that... Anyway, here's your reminder for milk" } }
After (fine-tuned) { "tool": "CategorizeTool", "params": { "text": "buy milk" } } ← parseable by JSONDecoder on the first try
12 / 15

Shipping It to the iOS App

  • Same bundle. The .litertlm file is added to the Xcode target. No re-exports, no server to stand up.
  • Whisper for input. Speech recognition runs on-device — voice prompts go straight into the same pipeline.
  • The ToolRegistry. Swift-side dispatcher that reads each tool call JSON and maps it to a registered tool (WeatherTool, CalendarTool, EventKit-backed reminders, etc.).
  • The handoff. After a tool runs, its result is fed back as context so the model can produce a final natural-language reply.
let engine = EngineManager() let registry = ToolRegistry() let conversation = try await engine .createConversation() // Stream tokens from the model for try await token in conversation .sendMessageStreaming(text) { // If the token is a tool call, dispatch it if let call = registry .parseToolCall(token) { let result = try await registry .execute(call) conversation.injectResult(result) } }
13 / 15

Questions?

Happy to dig into any part of this — the data generation, the LoRA setup, the Swift side, whatever.

14 / 15

Try It Here

The fine-tuned model running in your browser via WebGPU — same weights, no iPhone needed.

Nothing to install. Nothing leaves the tab.

Open the chat panel →

15 / 15

MobileActions Demo