On-Device LLM
β New in 1.3 β The killer story: run the model inside the phone itself. Works in airplane mode. Nothing leaves the device.
What βon-deviceβ actually means
Section titled βWhat βon-deviceβ actually meansβThereβs a big difference between βlocalβ and βon-deviceβ:
| Where model runs | What leaves the phone | |
|---|---|---|
| Cloud (Anthropic, OpenRouterβ¦) | Cloud GPU | Your prompts + tools |
| Local (Ollama, vLLM) | Your laptop / desk GPU | Prompts leave the phone (via LAN or SSH tunnel) β but stay on your network |
| On-device β | The phone itself | Nothing |
On-device is the only mode where the LLM literally executes inside the Ghost app process. The phone is both the agentβs body and its brain.
The 2 engines under one wrapper
Section titled βThe 2 engines under one wrapperβGhostβs OnDeviceLLM is a routing singleton β one Python entry point, two totally separate C++ inference engines:
| Engine | Model format | Where it comes from | Best for |
|---|---|---|---|
| MediaPipe LLM Inference | .task | Googleβs Android SDK β mobile-optimized runtime | Small Gemma models (1B, 2B) with fastest Android integration |
| llama.cpp (via JNI) | .gguf | Open-source ecosystem (ggerganov) + HuggingFace | Full model zoo β Gemma 4, Llama, Mistral, Qwen, DeepSeek, any GGUF |
OnDeviceLLM.ensureLoaded(modelId) reads OnDeviceModelRegistry metadata and routes to the right engine. Callers donβt know the difference.
Supported models
Section titled βSupported modelsβMediaPipe (.task format)
Section titled βMediaPipe (.task format)β| Model | Size | Where to get it |
|---|---|---|
gemma-3-1b-it | ~550 MB | HuggingFace (via Google) |
gemma-2-2b-it | ~1.4 GB | HuggingFace (via Google) |
llama.cpp (.gguf format)
Section titled βllama.cpp (.gguf format)β| Model | Size | Where to get it |
|---|---|---|
gemma-4-e2b-q4km-gguf | ~1.8 GB | Unsloth HuggingFace repo (Q4_K_M quant) |
Any other GGUF model on HuggingFace can be added to OnDeviceModelRegistry and run through the same llama.cpp engine β you just need enough RAM on the phone.
How it works β the stack
Section titled βHow it works β the stackββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Ghost Android APK (installed on the phone) ββ ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β Python layer (Chaquopy = full Python runtime inside APK) β ββ β β’ agent_chat_ondevice.py β ββ β β’ Builds Gemma chat template + calls Kotlin β ββ ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ ββ β jclass("...").INSTANCE.generate() ββ βΌ ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β Kotlin layer β ββ β β’ OnDeviceLLM singleton β routes by model.runtime β ββ βββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ€ ββ β MediaPipe path β llama.cpp path β ββ β βββββββββββββ β βββββββββββββ β ββ β LlmInference β LlamaCppLLM (JNI wrapper) β ββ β β β β β ββ β .task files β .gguf files β ββ βββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββ ββ ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β ModelDownloader.kt β in-app model download + cache β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββPython β Kotlin bridge
Section titled βPython β Kotlin bridgeβChaquopy embeds a full CPython runtime inside the Android APK. Ghostβs Python code (agent_chat_ondevice.py) can call any Kotlin class as if it were Python:
from java import jclassllm = jclass("com.ghostinthedroid.app.ondevice.OnDeviceLLM").INSTANCEllm.ensureLoaded("gemma-4-e2b-q4km-gguf")for token in llm.generate(prompt): ...Zero-copy across the JNI boundary. Same skill code that runs on the laptop dashboard runs unchanged inside the APK.
The Gemma chat template gotcha
Section titled βThe Gemma chat template gotchaβGetting Gemma to work reliably on-device took one non-obvious fix: use Gemmaβs canonical chat template, not a custom scaffold.
We initially wrapped prompts in [SYSTEM]/[USER]/[ASSISTANT] markers. Gemma had never seen those tokens in fine-tuning β so it happily echoed them back inside its own replies, βwritingβ fake [USER] turns from inside its assistant response. Multi-turn tool chains would spin out into infinite loops.
Fixed by switching to Gemmaβs native markers:
<start_of_turn>user{system prompt}
{user message}<end_of_turn><start_of_turn>modelMulti-turn tool chains work reliably now β verified on a Snapdragon 888 during the Ghost-Gemma hackathon push.
KV cache warmup
Section titled βKV cache warmupβOn-device inference cost is dominated by prompt prefill, not decode. Our system prompt + tool definitions run to ~4-6K tokens β recomputing that on every turn would kill latency.
OnDeviceLLM.warmup() pre-fills the KV cache once at app launch:
// MainActivity onCreateGlobalScope.launch { OnDeviceLLM.warmup(system, deviceStablePrefix) }Subsequent turns skip the prefill entirely. First-message latency drops from ~15s to sub-second on Snapdragon 8 Gen 3.
The stable prefix is computed in Python and matched on the Kotlin side:
def ondevice_stable_prefix(system: str, device: str) -> str: return f"<start_of_turn>user\n{system}\n\nDevice: {device}\n\n"Model download flow
Section titled βModel download flowβModels are downloaded in-app (not sideloaded):
- User opens Ghost app β picks a model in the picker
ModelDownloader.ktfetches the file from HuggingFace (or configured mirror)- File cached in the appβs internal storage (
context.filesDir) - Cached forever after β subsequent launches reuse
Model sizes range 550 MB (Gemma 3 1B) to ~1.8 GB (Gemma 4 E2B). Downloading over WiFi is recommended.
Performance expectations
Section titled βPerformance expectationsβRough numbers on a Snapdragon 8 Gen 3 (Samsung Galaxy S24 class):
| Model | Prefill (warmed) | Decode | Multi-turn feel |
|---|---|---|---|
Gemma 3 1B (.task, MediaPipe) | <1 s | ~20-30 tok/s | Snappy |
Gemma 2 2B (.task, MediaPipe) | 1-2 s | ~10-15 tok/s | OK |
Gemma 4 E2B (.gguf Q4_K_M, llama.cpp) | 1-2 s | ~8-12 tok/s | Best quality, still usable |
Snapdragon 888 (2020 flagship) will be roughly half these numbers. Anything below Snapdragon 8 Gen 1 struggles with 2B+ models.
When to use on-device
Section titled βWhen to use on-deviceβUse on-device when:
- You want a demo you can show on a plane / at a conference booth with no wifi
- Privacy matters β customer data, personal info, corporate secrets
- Youβre bandwidth-constrained (rural, satellite, expensive roaming)
- You want to prove a fully-offline autonomous agent works
Donβt use on-device when:
- You need the smartest available model (Claude 4, GPT-5 crush any on-device model on complex reasoning)
- Youβre driving from the laptop dashboard (on-device only runs inside the APK chat UI)
- You need long context windows (>8K tokens) β on-device is memory-constrained
Running from where?
Section titled βRunning from where?βOne subtle product point:
| Where youβre chatting from | Which provider runs on-device? |
|---|---|
| Ghost Android appβs chat UI | β Yes β LLM runs on the phone |
| Ghost web dashboard on your laptop | β No β LLM runs on your laptop or in cloud |
MCP client (Claude Code, Cursor) via ghost server | β No β the clientβs model handles reasoning |
The Ghost APK is required for on-device inference. If youβre driving Ghost from your laptop, βon-deviceβ isnβt in your provider options.
Adding a new on-device model
Section titled βAdding a new on-device modelβAdd an entry to OnDeviceModelRegistry.kt:
OnDeviceModel( id = "my-model-id", runtime = Runtime.LLAMA_CPP, // or MEDIAPIPE promptStyle = PromptStyle.GEMMA, // or RAW downloadUrl = "https://.../model.gguf", displayName = "My Model 7B (Q4_K_M)", sizeBytes = 4_200_000_000,)Then add the id to Pythonβs PROVIDERS["on-device"]["models"] list. Thatβs it.
For entirely new runtimes (Executorch, MLC, etc.) β implement an LLMBackend interface parallel to LlamaCppLLM, then dispatch in OnDeviceLLM.ensureLoaded().
Related
Section titled βRelatedβ- LLM Providers β the full list of ways to run models with Ghost
- MCP Server β how external agents drive Ghost (unrelated to on-device)
- Tracing β see exactly what tokens the on-device model produced