Documentation · v0.2
Run a coding model on the Air that everyone owns.
daedalus is a local, OpenAI-compatible MLX server that treats a fanless MacBook Air's thermal envelope as a first-class scheduling constraint. This is the complete reference — install, CLI, configuration, the HTTP API, and how the caching and thermal machinery actually work.
#Overview
Local coding agents — OpenCode, Hermes Agent, Pi Code — send 10–40k-token first prompts. On a passively-cooled Air, prefilling one of those is a multi-minute full-power GPU burn: the machine thermally throttles and the client times out. daedalus is built on mlx-lm and solves that with four ideas working together:
- The best prefill is no prefill. A persistent prefix cache (RAM + disk) means a given prompt pays its cold prefill exactly once, ever — even across restarts.
- Never redo work. Mid-prefill checkpoints every 4k tokens; a timeout or crash resumes where it stopped instead of restarting a 30k prefill.
- Duty-cycle the burn. A governor reads macOS's 5-level thermal signal and paces prefill so the silicon runs unthrottled whenever it runs.
- Never let the client die. SSE keepalives stream progress, thermal state, and ETA from the first second, and tool-call deltas are always well-formed.
Qwen3.5-9B-MLX-4bit. Anything mlx-lm can load will run; the design
target is the fanless Air.
#Installation
Install from a release wheel (recommended) — it puts daedalus on
your PATH:
# from a release wheel — recommended uv tool install https://github.com/therahul-yo/Daedalus/releases/download/v0.2.0/daedalus-0.2.0-py3-none-any.whl # or from source (editable) uv tool install --editable . --with "transformers>=5.0,<5.11" # or pipx install .
Optional extras: --audit-log, --log-json,
and the observability extra
(pip install 'daedalus[observability]' for structlog +
OpenTelemetry).
Confirm the thermal sensor and MLX are wired up before your first serve:
daedalus doctor # ✓ thermal sensor com.apple.system.thermalpressurelevel → NOMINAL # ✓ mlx / mlx-lm metal backend ready # ✓ disk cache ~/.cache/daedalus writable
#Quickstart
Start the server, then point any OpenAI-compatible agent at it.
daedalus serve --model mlx-community/Qwen3.5-9B-MLX-4bit --port 8484
The banner reports the model, memory, cache entries, and live thermal state. Every request is logged: cache hit/miss, prefill progress with tok/s and thermal level, decode rate, and finish reason.
Call it like OpenAI
curl http://127.0.0.1:8484/v1/chat/completions \ -H 'Content-Type: application/json' \ -d '{ "model": "mlx-community/Qwen3.5-9B-MLX-4bit", "messages": [{"role": "user", "content": "Refactor this function."}], "stream": true }'
On the first turn you'll see a cold prefill paced by the governor; on the agent's next turn — same prefix, one token longer — the cache serves it in a fraction of a second:
10:41:22 │ → f3ab21 · 8,027 tok · 4 tools · stream 10:41:22 │ f3ab21 · cache hit 8,026/8,027 (disk) — prefilling 1 10:41:24 │ ← f3ab21 · 2.4s · decode 48 tok @ 20.4 tok/s · NOMINAL 10:44:07 │ → 9c11e0 · 21,458 tok · cold prefill 10:44:39 │ 9c11e0 · prefill 52% @ 157 tok/s · MODERATE — pacing 10:46:12 │ ← 9c11e0 · 124.8s · checkpointed · recovered → NOMINAL
Pre-warm expensive prefixes while the machine is cool so the first real request is already a cache hit:
daedalus warm --model mlx-community/Qwen3.5-9B-MLX-4bit --prompts prompts.json
#CLI commands
| Command | What it does |
|---|---|
daedalus doctor | Verify the thermal sensor, MLX/Metal backend, and disk-cache path before serving. |
daedalus serve | Start the OpenAI-compatible server. The main command — see Configuration. |
daedalus warm | Pre-prefill a set of prompts into the cache while the machine is cool. |
daedalus tune | Measure the fastest safe prefill chunk for this model + Mac, and emit a recommendation. |
daedalus status | Query a running server: model, thermal state, cache, and queue at a glance. |
daedalus cache | Inspect and maintain the persistent cache — stats, TTL eviction, and clearing. |
--help. Run daedalus serve --help
for the full, version-accurate flag list — the table below covers the ones you'll reach for most.
#Configuration
Flags for daedalus serve, grouped by concern.
Model & server
| Flag | Effect |
|---|---|
--model <id> | The MLX model to load (HF repo id or local path). |
--port <n> | Listen port. Examples here use 8484. |
--host <addr> | Bind address. Defaults to 127.0.0.1; use 0.0.0.0 only with an API key. |
--model-revision <id> | Pin a mutable model name to an immutable revision so caches stay valid. |
--swap-model <id> | Register an additional swappable model (see multi-model swap). |
Thermal & prefill
| Flag | Effect |
|---|---|
--profile <p> | Governor profile: performance, balanced, or cool. |
--max-duty <0–1> | Cap the prefill duty cycle. --max-duty 0.5 = quiet mode. |
--anticipate-rising | Pace one thermal level ahead when pressure is trending up (opt-in, overlays hysteresis). |
--prefill-chunk-tokens <n> | Nominal-state prefill chunk size — use the value daedalus tune recommends. |
--clear-metal-cache-between-chunks | Free Metal allocations between chunks. Trades throughput for lower memory pressure. |
Cache & memory
| Flag | Effect |
|---|---|
--cache-ram-mb <n> | RAM budget for the in-memory LRU KV cache. |
--cache-disk-gb <n> | On-disk budget for persistent KV snapshots. |
--cache-ttl-days <n> | Evict cache entries older than N days (good for shared long-running machines). |
--max-active-memory-gb <n> | Conservative ceiling on active unified memory. |
--max-pending-requests <n> | Active-or-queued request cap (default 8); returns 429 when full. |
Security & observability
| Flag | Effect |
|---|---|
--api-key <secret> | Require Authorization: Bearer on /v1. Required to bind non-localhost. |
--api-key-env <VAR> | Read the key from an env var instead of the command line (keeps it out of shell history). |
--api-key-file <path> | Read the key from a file. |
--trusted-proxy-host <addr> | Peer allowed to set X-Forwarded-For; ignored from every other peer. |
--audit-log <path> | Write an NDJSON audit log of requests. |
--log-json | Emit structured JSON logs instead of the human banner. |
#HTTP API
daedalus speaks the OpenAI Chat Completions protocol, so existing clients work unchanged. Base URL:
http://127.0.0.1:8484/v1
Inference
reasoning_content separation.Operational
{"status":"ok"} always; model/thermal/memory detail is added when authorized.Cache
reasoning_content
so it never leaks into the visible reply. Clients that don't understand reasoning deltas can
ignore that optional field.
#Agent integrations
Any OpenAI-compatible client works: set its provider base URL to
http://127.0.0.1:8484/v1 and select the loaded model ID. Leave
streaming on — daedalus sends SSE keepalives while a cold prefill is in progress.
| Client | Setup |
|---|---|
| OpenCode | OpenAI-compatible provider → base URL above + the daedalus model ID. Keep streaming enabled. |
| Pi Code | OpenAI-compatible provider mode. Tool-call deltas and reasoning_content are supported. |
| Hermes Agent | Same OpenAI-compatible provider mode; chat streaming + tool calls verified. |
| Continue / VS Code | Add an OpenAI-compatible model with the base URL and model ID. |
For a LAN deployment, terminate TLS at a reverse proxy (Caddy, nginx), restrict it to your
LAN/VPN, and forward the Authorization header unchanged. Full notes in
integrations.md.
#Prefix cache
Coding agents are stateless — they resend the whole conversation every turn. daedalus keeps a persistent prefix cache: RAM LRU on top of disk safetensors, with atomic, corruption-tolerant writes. After the first prefill, the Air never re-burns the GPU for that prefix again — even across restarts.
| Request | TTFT | Cached tokens |
|---|---|---|
| cold (first ever) | 52.2 s | 0 |
| warm (agent's next turn) | 0.24 s | 8,026 |
Crucially, the cache is correct for hybrid-attention models — Qwen3.5's Gated-DeltaNet layers, Gemma 4's sliding windows — whose caches cannot simply be trimmed. It uses exact-prefix matching with end-of-prefill snapshots: the reuse stock mlx-lm's server silently misses for these models.
--model-revision when a model name may resolve to mutable weights.
#Resumable prefill
A cold 30k prefill can outlast a client's hard timeout. daedalus checkpoints every 4k tokens, so a client timeout, crash, or restart resumes exactly where it stopped instead of restarting the whole prefill. Combined with the prefix cache, completed work is never repeated — the retry picks up mid-prompt.
#Thermal governor
The governor reads macOS's 5-level thermal-pressure signal
(com.apple.system.thermalpressurelevel, no sudo —
finer than NSProcessInfo, which hides the level where throttling
actually starts) and duty-cycles prefill against it: full speed at Nominal, smaller chunks
plus idle gaps at Moderate, hold at Heavy. It escalates instantly and de-escalates with
hysteresis, because a fanless chassis cools slowly.
Measured over four consecutive distinct 20k-token prefills (80,196 tokens, cache disabled — the unavoidable cold worst case), cooled to Nominal before each arm:
| Governor OFF | Governor ON | |
|---|---|---|
| Wall clock | 647 s | 1012 s |
| GPU burn time | 647 s | 515 s (−20%) |
| Burn rate / round | 133 → 119 tok/s | 152 → 157 tok/s |
| Time at HEAVY (throttled) | 50% | 21% |
Unpaced, the Air hits HEAVY inside the first prefill, throttles ~10% immediately, and keeps degrading. Paced, the silicon runs unthrottled whenever it runs — the same work costs 20% fewer GPU-seconds (less total heat) at a stable rate, in exchange for longer wall-clock spread across idle gaps that keep the machine usable. With the prefix cache, this cost is paid once per unique prefix, ever.
| Profile | Behaviour |
|---|---|
performance | Push the thermal envelope; pace only at the highest pressure. |
balanced | Default. Trade a little wall-clock for a stable, non-decaying rate. |
cool | Keep the chassis coolest; the quietest, gentlest duty cycle. |
#Keepalives & streaming
A cold prefill can run for minutes — longer than many clients' idle timeout. daedalus streams SSE keepalives carrying progress, thermal state, and ETA from the first second of prefill, so idle-timeout clients never disconnect.
Tool-call streaming is strict: OpenAI-format deltas with explicit index,
never an empty tool_calls array — the malformed shapes that hang agents
are structurally impossible. Tool calling rides mlx-lm's auto-detected per-model parsers
(Qwen, Gemma 4, …) behind a streaming marker filter, so any model mlx-lm can parse tools
for works here too.
#Multi-model swap
main,
but real-hardware swap validation is still pending — it is not in the v0.2.0 release.
Register extra models with --swap-model. A request for a
non-resident model triggers a hot-swap that tears the current engine down — releasing
its Metal memory and cache flock — before loading the target, so only one model is ever
resident on your 16 GB. Admission is checked against a derived, hybrid-aware weights+KV
estimate; swaps are rate-limited by a cooldown; and a failed target load restores the
previous model rather than stranding the server.
#Operating safely
daedalus binds to 127.0.0.1 by default. To expose it on a LAN, an
API key is required explicitly:
daedalus serve --model ... \ --host 0.0.0.0 \ --api-key-env DAEDALUS_API_KEY
- Prefer
--api-key-envor--api-key-fileover--api-keyso the secret never lands in shell history. - Terminate TLS at a local reverse proxy before the service is LAN-reachable.
- The server admits at most 8 active-or-queued requests by default
(
--max-pending-requests) and returns a standard 429 when full, rather than accumulating unbounded worker threads. - Use
--trusted-proxy-hostsoX-Forwarded-Foris honored only from your proxy.
For a persistent Mac service, customize
scripts/com.daedalus.server.plist.template and load it with
launchctl bootstrap. Keep the secret in a root-readable env file or
secret manager — never commit it into the plist.
#Tuning & benchmarks
The fastest safe prefill chunk varies by model, RAM, and Mac generation. Measure it from a
cool machine, then pass the recommendation to serve:
daedalus tune --model mlx-community/Qwen3.5-9B-MLX-4bit --out tune.json
# use the returned recommended_prefill_chunk_tokens value
daedalus serve --model ... --prefill-chunk-tokens 4096
The tuned size applies only while thermal pressure is Nominal — daedalus still switches to smaller thermal-profile chunks automatically when the laptop gets hot.
vs llama.cpp
Same machine, same 8,000-token agent prompt, Qwen3.5-9B at 4-bit (MLX 4-bit vs GGUF
Q4_K_M), identical protocol via bench/head_to_head.py --wait-nominal so
neither engine inherits the other's heat:
| cold TTFT | warm TTFT | after restart | decode | |
|---|---|---|---|---|
| daedalus | 50.8 s | 0.22 s | 0.55 s | 18.2 tok/s |
| llama-server | 57.3 s | 0.60 s | 78.6 s | 13.8 tok/s |
The decisive column is the restart: llama.cpp's slot cache dies with the process, so a
daemon restart re-pays the full 8k prefill — on a chassis its own workload just heated —
while daedalus reloads the snapshot from disk in half a second. Reproduce with
python bench/head_to_head.py --wait-nominal.
Release gating
Compare two real-model benchmark artifacts; the default gate allows at most 10% TTFT and 5% prefill-throughput regression:
python bench/regression.py baseline.json candidate.json
#Troubleshooting
| Symptom | Fix |
|---|---|
| Client times out on the first request | That's the cold prefill. Keep streaming enabled so keepalives flow, or daedalus warm the prefix first while cool. |
| Every request is a cache miss | The prefix isn't byte-identical, or the cache key changed after an MLX/model upgrade. Pin with --model-revision. |
| Prefill feels slow / throttled | Expected under HEAVY pressure — the governor is protecting the chassis. Try --profile performance from a cool start, or daedalus tune. |
| 429 responses under load | You hit --max-pending-requests (default 8). Raise it, or let the client back off. |
| Can't reach it from another machine | It binds 127.0.0.1. Add --host 0.0.0.0 and an API key — see Operating safely. |
#FAQ
Does it work on an Intel Mac or non-Apple hardware?
No. daedalus is built on MLX and requires an Apple Silicon Mac. The thermal governor reads an Apple-specific pressure signal.
Which models are supported?
Anything mlx-lm can load. The validated target is Qwen3.5-9B-MLX-4bit;
Gemma 4 E4B / 12B MLX 4-bit are designed for and next up for a real-model run.
Why not just use ollama or LM Studio?
They don't persist KV across restarts and they aren't thermally aware. On a fanless Air a cold agent prompt throttles the machine and a restart re-pays the whole prefill — the two exact problems daedalus is built to remove. See the benchmarks.
Is my chat data sent anywhere?
No. Everything runs locally. The only network access is downloading model weights the first time (via Hugging Face / mlx-lm), exactly like any local MLX setup.
What's the license?
Apache-2.0.