This is the one-stop guide to running Memex fully locally on an Apple-silicon Mac — written against an M5 Max / 64 GB dev box but valid for any recent M-series machine. Three independent pieces, each opt-in behind a flag so you only pay for what you turn on:
- On-device voice — Swiss-German speech-to-text in the native MAUI client (Whisper + CoreML on the GPU).
- Local AI layer — Qwen3-Coder running in native Ollama on the Metal GPU, wired into the portal.
- Local observability — a Grafana/Loki/Tempo/Prometheus (LGTM) stack in the Aspire cluster.
The one rule that explains every Mac-specific choice below: on Apple silicon, the GPU is reachable from native (host) processes, NOT from Docker containers — Docker Desktop on macOS has no GPU passthrough. So anything that must use the GPU (Whisper, Ollama) runs natively; only the things that don't need a GPU (Loki, Grafana, …) run in containers.
0. The hardware ceiling
| M5 Max / 64 GB (this box) | |
|---|---|
| Unified memory | 64 GB — shared CPU+GPU, so "VRAM" = system RAM |
| What fits for a local LLM | Anything whose weights fit in ~that, with headroom for the OS + portal + Postgres |
| GPU access | Native processes only (Metal). Docker = CPU-only. |
This ceiling is why model choice matters — see §2.
1. On-device voice (Whisper and Swiss German)
Full detail in On-device voice. The Mac-relevant essentials:
- The macOS client is MacCatalyst, and Whisper.net builds whisper.cpp with
GGML_METAL=OFFfor that target — so unlike the iOS device, the macOS client has no Metal. The GPU path on macOS is CoreML. - GPU therefore needs the CoreML "apple image" (
ggml-swiss-german-turbo-encoder.mlmodelc, fp16, ~1.2 GB) next to the ggml model. It's generated withcoremltools(no Xcode needed —compile_modeluses the OS CoreML framework) and runs the dominant encoder of the large-v3-turbo model on the Apple GPU / ANE.
Feature flags (device-persisted, Preferences-backed — Services/FeatureFlags.cs)
| Flag | Default | Effect |
|---|---|---|
swiss-german |
off | off → small multilingual Base model (~140 MB, English default). on → the 547 MB Swiss-German fine-tune (+ the 1.2 GB CoreML apple image on macOS). |
apple-gpu |
off | macOS only. on → prefer the CoreML runtime + download the apple image so the encoder runs on the GPU/ANE; off → CPU. Flip on after the apple image is hosted. |
Language: the Voice page defaults to English; other languages are optional from the dropdown. With
swiss-german on, the default switches to the de/Swiss-de/en-first auto mode.
Building/running the MacCatalyst client needs full Xcode (not just Command-Line-Tools) + the
mauiworkload (dotnet workload install maui).
2. Local AI layer: Qwen via native Ollama
The portal's AI runs through IChatClientFactory providers (see the model provider design).
The framework already ships an OpenAI-wire-compatible provider (AddOpenAICompatible), and Ollama
exposes the OpenAI API at http://localhost:11434/v1 — so the local AI layer is config, not new code.
Why native Ollama, not a container
A containerized Ollama on macOS runs Qwen on the CPU (no Metal passthrough). Run Ollama natively so
it uses the M5 Max GPU. The portal runs as a host process in mode=local, so it reaches native Ollama
on localhost directly — no container, no host.docker.internal gymnastics.
brew install ollama # or the Ollama.app
ollama serve # native — uses Metal
ollama pull qwen3-coder:30b # ~18 GB (Q4); first pull only
Which Qwen — the 35B trap
Use qwen3-coder:30b = Qwen3-Coder-30B-A3B (30 B total / 3 B active MoE). At Q4 it's ~18 GB and,
because only 3 B params are active per token, it's fast on the M5 Max GPU — ideal for 64 GB.
🚫 Not Qwen3-Coder-480B-A35B. Its "A35B" is the active MoE parameters of a 480 B-total model;
an MoE must hold all experts resident, so its GGUFs are 150 GB (IQ1) – 276 GB (Q4) and do not fit in
64 GB. "35B" is not the model size.
Turn it on
--localai true sets, on the portal (MemexLocalStack.AddLocalAi):
Features__Ai__Providers__OpenAICompatible = true
OpenAICompatible__Endpoint = http://localhost:11434/v1
OpenAICompatible__ApiKey = ollama # Ollama ignores it; the factory requires non-empty
OpenAICompatible__Models__0 = qwen3-coder:30b
BuiltInLanguageModelProvider reads those and emits the qwen3-coder:30b model node, so it appears in the
chat composer for portal / web users.
This is the cluster's AI, not the native app's. The MAUI app does not use Qwen — it ships only the Swiss-German Whisper model and uses Apple Intelligence (the OS FoundationModels) for text AI on iPhone and Apple-silicon Macs. See On-device AI and logging. A native-app voice transcript that is routed to the mesh (when connected) can still reach this Qwen, but nothing is bundled into the app.
3. Local observability (LGTM)
OpenTelemetry is already emitted by the portal + db-migration (ServiceDefaults.ConfigureOpenTelemetry()
→ logs, metrics, traces over OTLP). By default Aspire routes that to its own dashboard. --observability true
adds a full LGTM stack and routes the same OTLP into it (MemexLocalStack.AddObservability):
portal / db-migration ──OTLP/HTTP──▶ otel-collector ─┬─ logs ─▶ Loki
(already instrumented) ├─ traces ─▶ Tempo
└─ metrics─▶ Prometheus
└──────▶ Grafana (all 3 datasources)
| Container | Image | Port | Role |
|---|---|---|---|
otel-collector |
otel/opentelemetry-collector-contrib |
4318 (OTLP/HTTP) | receive + fan out |
loki |
grafana/loki |
3100 | logs (native OTLP ingest) |
tempo |
grafana/tempo |
3200 | traces |
prometheus |
prom/prometheus |
9090 | metrics (remote-write receiver) |
grafana |
grafana/grafana |
3000 | dashboards (anonymous admin, datasources pre-provisioned) |
These are CPU-only, so containers are fine. Configs live in ../MeshWeaver.Plugins/src/Memex.AppHost/observability/;
data persists in volumes across restarts. OTLP uses HTTP/protobuf (4318) rather than gRPC to avoid
http/2-through-proxy fragility. Open Grafana from the Aspire dashboard's resource list → Explore → Loki.
Opt-in by design: with the flag off, a plain
aspire runis unchanged and telemetry stays on the Aspire dashboard. With it on, the services' OTLP is redirected to the collector (so it lands in Grafana).
4. Running it all
Everything is mode=local (Docker pgvector + emulated storage). The two extras are opt-in flags:
# Native, GPU-backed (run once / leave running):
ollama serve & # only if you enabled --localai
ollama pull qwen3-coder:30b
# The local cluster, with both extras:
aspire run --project ../MeshWeaver.Plugins/src/Memex.AppHost -- --observability true --localai true
# • plain: aspire run --project ../MeshWeaver.Plugins/src/Memex.AppHost
# • observability only: ... -- --observability true
# • local AI only: ... -- --localai true
Then, from the Aspire dashboard (https://localhost:17200/): open grafana (3000) for logs/traces/
metrics, and the portal for the app. The MAUI client is separate — see §1.
Mac gotchas, collected
| Symptom | Cause | Fix |
|---|---|---|
| Qwen is slow / pegs CPU | Ollama running in Docker | Run Ollama natively (brew/.app); Docker on macOS has no Metal |
| macOS client voice on CPU | MacCatalyst has no Metal (Whisper.net GGML_METAL=OFF) |
Host the CoreML apple image + flip apple-gpu on (OnDeviceVoice) |
| MacCatalyst build fails | only Command-Line-Tools installed | install full Xcode + dotnet workload install maui |
| Grafana empty | flag off, or services started before the collector | --observability true; the collector WaitFors its backends and the portal WaitFors the collector |
qwen3-coder:30b won't load |
confused with the 480B-A35B | use the 30B-A3B tag — the 480B does not fit 64 GB (§2) |
Code map
| Concern | File |
|---|---|
| Local-cluster extras (LGTM + Ollama/Qwen wiring, opt-in flags) | ../MeshWeaver.Plugins/src/Memex.AppHost/MemexLocalStack.cs |
| Observability configs (collector, Loki, Tempo, Prometheus, Grafana datasources) | ../MeshWeaver.Plugins/src/Memex.AppHost/observability/ |
| OpenTelemetry emission (already wired) | memex/aspire/Memex.Portal.ServiceDefaults/ServiceDefaults.cs |
| OpenAI-compatible provider (serves Ollama) | src/MeshWeaver.AI.OpenAI/OpenAIExtensions.cs |
| On-device voice + CoreML apple image | On-device voice · memex/Memex.Client/Voice/ |
| Client feature flags | memex/Memex.Client/Services/FeatureFlags.cs |