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.
Requirements An Apple Silicon Mac (MLX). Validated on an M4 MacBook Air 16 GB with 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:

shell
# 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:

shell
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.

shell
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
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:

daedalus serve — 127.0.0.1:8484
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:

shell
daedalus warm --model mlx-community/Qwen3.5-9B-MLX-4bit --prompts prompts.json

#CLI commands

CommandWhat it does
daedalus doctorVerify the thermal sensor, MLX/Metal backend, and disk-cache path before serving.
daedalus serveStart the OpenAI-compatible server. The main command — see Configuration.
daedalus warmPre-prefill a set of prompts into the cache while the machine is cool.
daedalus tuneMeasure the fastest safe prefill chunk for this model + Mac, and emit a recommendation.
daedalus statusQuery a running server: model, thermal state, cache, and queue at a glance.
daedalus cacheInspect and maintain the persistent cache — stats, TTL eviction, and clearing.
Tip Every command takes --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

FlagEffect
--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

FlagEffect
--profile <p>Governor profile: performance, balanced, or cool.
--max-duty <0–1>Cap the prefill duty cycle. --max-duty 0.5 = quiet mode.
--anticipate-risingPace 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-chunksFree Metal allocations between chunks. Trades throughput for lower memory pressure.

Cache & memory

FlagEffect
--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

FlagEffect
--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-jsonEmit structured JSON logs instead of the human banner.

#HTTP API

daedalus speaks the OpenAI Chat Completions protocol, so existing clients work unchanged. Base URL:

base url
http://127.0.0.1:8484/v1

Inference

POST/v1/chat/completionsChat completions, streaming or not — with tool-call deltas and reasoning_content separation.
GET/v1/modelsList the resident (and registered swappable) model IDs.

Operational

GET/healthLiveness. Returns {"status":"ok"} always; model/thermal/memory detail is added when authorized.
GET/readyzAdmission readiness — is the server able to accept a request right now.
GET/metricsPrometheus text: cache, queue-wait, TTFT / decode histograms, request outcomes, thermal level.

Cache

GET/v1/cache/statsAuthenticated cache inspection — entries, sizes, hit rates.
DELETE/v1/cacheClear the cache when the server is idle (authenticated).
Reasoning models Chain-of-thought from thinking models is split into 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.

ClientSetup
OpenCodeOpenAI-compatible provider → base URL above + the daedalus model ID. Keep streaming enabled.
Pi CodeOpenAI-compatible provider mode. Tool-call deltas and reasoning_content are supported.
Hermes AgentSame OpenAI-compatible provider mode; chat streaming + tool calls verified.
Continue / VS CodeAdd 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.

RequestTTFTCached tokens
cold (first ever)52.2 s0
warm (agent's next turn)0.24 s8,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.

Isolation Persistent caches are keyed by model, tokenizer, MLX/MLX-LM versions, KV layout, and machine architecture — so an upgrade can never load an incompatible KV snapshot. Pass --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 OFFGovernor ON
Wall clock647 s1012 s
GPU burn time647 s515 s (−20%)
Burn rate / round133 → 119 tok/s152 → 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.

ProfileBehaviour
performancePush the thermal envelope; pace only at the highest pressure.
balancedDefault. Trade a little wall-clock for a stable, non-decaying rate.
coolKeep 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

On main · toward v0.3 Swap-only multi-model serving is implemented and unit-tested on 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:

shell
daedalus serve --model ... \
  --host 0.0.0.0 \
  --api-key-env DAEDALUS_API_KEY
  • Prefer --api-key-env or --api-key-file over --api-key so 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-host so X-Forwarded-For is 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:

shell
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 TTFTwarm TTFTafter restartdecode
daedalus50.8 s0.22 s0.55 s18.2 tok/s
llama-server57.3 s0.60 s78.6 s13.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:

shell
python bench/regression.py baseline.json candidate.json

#Troubleshooting

SymptomFix
Client times out on the first requestThat's the cold prefill. Keep streaming enabled so keepalives flow, or daedalus warm the prefix first while cool.
Every request is a cache missThe prefix isn't byte-identical, or the cache key changed after an MLX/model upgrade. Pin with --model-revision.
Prefill feels slow / throttledExpected under HEAVY pressure — the governor is protecting the chassis. Try --profile performance from a cool start, or daedalus tune.
429 responses under loadYou hit --max-pending-requests (default 8). Raise it, or let the client back off.
Can't reach it from another machineIt 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.