Run the best open-weights models locally on Apple Silicon, and plug them into the tools you already use.
fxlla runs models with MLX or llama.cpp, caches the weights on an external
disk, downloads them with a bandwidth cap, and serves them over an
OpenAI-compatible endpoint that integrates with opencode. Claude Code keeps its
own Claude models untouched.
Built for M-series Macs with large unified memory. Validated on M5 Max (128 GB).
- One command to start or stop a local model, on the right engine, with the weights on the disk you choose.
- Uses the full unified memory:
fxlla ramlifts the macOS GPU memory cap so large models fit. - Bandwidth-capped, resumable downloads so pulling a 60 GB model does not saturate your connection.
- Frees RAM on its own after an idle timeout.
- Adds local models to opencode without touching Claude Code.
- Ships local tools any client can call over MCP: RAG knowledge bases, a multi-language code graph, and image, video, and speech generation.
- Apple Silicon Mac (M-series).
- Homebrew and
uv(brew install uv);aria2is installed byfxlla setupif missing. - An external disk or a folder with room for the weights.
git clone https://github.com/HowDidTheCatGetSoFat/fxlla.git
cd fxlla
mkdir -p ~/.local/bin && ln -sf "$PWD/bin/fxlla" ~/.local/bin/fxlla
mkdir -p ~/.config/fxlla && cp config/config.env.example ~/.config/fxlla/config.env
$EDITOR ~/.config/fxlla/config.env # optional: FXLLA_STORE, if not ~/.local/share/fxlla/store
fxlla setupfxlla setup installs mlx-lm and llama.cpp and verifies the environment.
fxlla setup --media also installs the media backends (image, video, voice)
at pinned versions - determinism comes from pins, not from bundling bytes,
since relocatable Python venvs cannot be signed into the app. Weights stay
separate and consent-gated. The one manual piece is voice's reference wav
(FXLLA_VOICE_REF): cloning a voice needs a voice.
fxlla models # browse the catalog
fxlla pull tiny # small model to validate the pipeline (~0.3 GB)
fxlla on tiny # start and register it in opencode
fxlla status # running model and idle time
fxlla off # stop
fxlla pull qwen3-coder # the daily driver (MLX, ~17 GB)Then open opencode and pick the local provider.
| Command | What it does |
|---|---|
fxlla setup |
Install and verify dependencies |
fxlla models |
List the catalog |
fxlla pull <model> |
Download a model (bandwidth-capped, resumable) |
fxlla ls [--json] |
List downloaded models |
fxlla avail <alias> |
Availability as JSON (cached, size, engine) |
fxlla on [model] [--pull] |
Start a single model, register in opencode |
fxlla off |
Stop the single-model server |
fxlla serve / unserve |
Multi-model gateway: one endpoint, load on demand |
fxlla status |
Server, model, and idle status |
fxlla stats [--watch] |
Live tok/s, TTFT, RAM (also --json, --last N) |
fxlla ram [auto|reset|persist|unpersist|<MB>] |
Adjust the GPU memory limit |
fxlla eval [model ...] |
Score chat models on quality and speed |
fxlla kb ... |
Local RAG knowledge bases (MCP: rag_search) |
fxlla graph ... |
Multi-language code graph (MCP: find_definition, ...) |
fxlla media image|video|voice |
Local media generation (MCP: generate_*) |
fxlla logs |
Follow the server log |
fxlla skills install |
Install the tool-usage skill pack |
fxlla doctor |
Diagnose the environment |
fxlla completions <bash|zsh> |
Print a shell completion script |
fxlla wire-opencode |
Register the local provider in opencode |
fxlla rm <model> |
Delete a downloaded model |
fxlla config |
Show the effective configuration |
Both are set in ~/.config/fxlla/config.env, which is never committed:
-
FXLLA_STOREis where models are cached. Point it at any mounted disk or a local folder. -
HF_TOKENis only needed for gated Hugging Face repositories. Leave it unset otherwise. -
FXLLA_CIVITAI_TOKENletsfxlla pull civitai:<id>fetch LoRAs and checkpoints from civitai.com (into<store>/civitai):fxlla pull civitai:12345 # a Civitai model-version id fxlla pull https://civitai.com/models/999?modelVersionId=12345
Never put tokens anywhere else in the tree. config/config.env is git-ignored.
An exported environment variable always wins over config.env, which in turn
wins over the built-in defaults.
Completion for commands, catalog aliases (pull), downloaded models
(on/off/rm/eval), and kb/graph/media/skills subcommands:
# bash: add to ~/.bashrc
source <(fxlla completions bash)
# zsh: add to ~/.zshrc (after compinit)
source <(fxlla completions zsh)Check what is cached before starting anything. fxlla ls --json lists cached
models and fxlla avail <alias> reports availability for any model:
fxlla avail qwen3-coder
# {"alias": "qwen3-coder", "cached": false, "known": true, "engine": "mlx",
# "repo": "...", "size_mb": null, "catalog_size": "17GB"}
# size_mb is the real disk size when cached, else null; catalog_size is the
# human-readable download estimate for a model that is not cached yet.fxlla on <alias> fails fast when the model is not cached; pass --pull to
download first.
Any transfer over FXLLA_CONFIRM_ABOVE_GB (5 GB by default) needs consent. At a
terminal you get a prompt with the size. Everywhere else (a script, an agent, an
MCP call, or the weights a render would fetch on first use) it refuses and says to
re-run with --yes, so tens of gigabytes never start unattended. --yes or
FXLLA_ASSUME_YES=1 authorizes, and a size the CLI cannot read is treated as
large rather than waved through. Resuming an interrupted pull asks about the
remainder, not the original size.
MLX is the default and the fastest path on this hardware; the mlx-community
repositories already ship quantized for MLX. GGUF (llama.cpp) is for models
with no MLX build or when you want a specific imatrix quant:
fxlla pull coder32-gguf --quant Q4_K_MIf a GGUF repository has several quants and you omit --quant, fxlla lists
them. GGUF cannot be converted to MLX; for Hugging Face safetensors to MLX use
mlx_lm.convert -q.
Pulls use aria2c with a bandwidth cap by default. For a xet-backed or awkward
repo, fxlla pull <repo> --downloader hf fetches it with the Hugging Face CLI
(run via uvx, no extra install); it is more robust but ignores the cap.
Rather than trusting the catalog's prose, measure. fxlla eval runs every
cached dev/agentic/max model - one at a time, each on a cold dedicated
server - through 30 mechanically checked tasks (code executed against asserts
in a sandbox, structured tool calls, instruction following, long-context
recall) and reports them next to cold cost, TTFT, decode and prefill speed,
peak RSS, and tokens spent:
fxlla eval # every cached dev/agentic/max model
fxlla eval qwen3-coder # just one (named models skip the RAM guard)
fxlla eval --quick # pipeline check in ~2 minutesNo model judges another model's prose: every check is execution, parsing, or
string mechanics, so scores cannot depend on a judge. Each run prints a task
fingerprint and a harness version; two runs are comparable exactly when both
match, and scores are per-machine. The report states its own noise floor. The
details - what is measured, the sandbox, why the text-channel tool-call column
exists - live in evals/README.md.
macOS reserves about 75 percent of RAM for the GPU. For large models raise it:
fxlla ram # show current and recommended limit
fxlla ram auto # raise it (asks for sudo, keeps a reserve for the OS)
fxlla ram reset # back to defaultfxlla ram auto reverts on reboot. To make it stick, fxlla ram persist
installs a small LaunchDaemon that re-applies the limit at every boot;
fxlla ram unpersist removes it.
FXLLA_KEEP_WARM (minutes, default 10) stops the server after that idle time
to free RAM. Set 0 to disable. fxlla status shows the idle timer.
fxlla on serves one model. fxlla serve starts a gateway: a single
OpenAI-compatible endpoint that fronts every downloaded chat model.
fxlla serve # start the gateway on 127.0.0.1:8080
fxlla status # gateway, resident models, RAM budget
fxlla unserve # stop it (unloads all backends)GET /v1/models lists every downloaded chat model (embedding models are
excluded; they cannot chat and belong to fxlla kb). A request picks one by
name:
curl -s localhost:8080/v1/chat/completions -d '{"model":"qwen3-coder", ...}'The gateway loads the requested model on demand, keeps several small models
resident for instant switching, and evicts the least-recently-used one when a
load would exceed the RAM budget (FXLLA_GATEWAY_BUDGET_MB, derived from the
GPU limit by default). In opencode you pick any local model from the local
provider and the gateway handles loading.
While it serves, the gateway measures real traffic: it derives tokens/s and
time to first token from the streams it proxies and appends them to the stats
time-series, so fxlla stats and the menu bar charts reflect actual usage
rather than a synthetic probe. See gateway/README.md.
A native SwiftUI menu bar app lives in app/: live status, time-series charts
(tokens/s, TTFT, RAM in GB), gateway start/stop, model list with on-demand
load, and a GPU RAM toggle. It is a thin front end over the CLI. See
app/README.md.
app/build.sh && open app/fxlla.app # unsigned local build
app/package-dmg.sh --check # verify signing prerequisites
app/package-dmg.sh --notarize <profile> # signed, notarized, stapled .dmgDistribution builds are signed with a Developer ID and notarized. Create the
notarytool keychain profile once (with an app-specific password or an App
Store Connect API key); app/package-dmg.sh reads it from the keychain and
never from the repo. See app/package-dmg.sh for the exact commands.
The app ships the CLI inside its bundle, so installing the .dmg alone gives a
working fxlla. To use it from a terminal, click Install the fxlla command
in the panel: it symlinks the bundled CLI into ~/.local/bin (no admin prompt)
and tells you where. That is a deliberate user action, not something the
installer does silently, and it never replaces a file or a link it did not
create - if you already run fxlla from a git checkout, it says so and leaves
your setup alone.
Index your files into a local knowledge base and search them semantically. It uses a small local embedding model (via llama.cpp) and a SQLite store; nothing leaves the machine.
fxlla pull embed --quant Q5_K_M # one-time: the embedding model (~100 MB)
fxlla kb add docs ./docs README.md # index files or directories
fxlla kb search docs "how does eviction work"
fxlla kb lsExpose it to your tools as an MCP server so any model can call rag_search:
fxlla kb wire-opencode # register the RAG MCP in opencodefxlla models lists several under the embed role, from 384 to 1024
dimensions. FXLLA_EMBED_MODEL picks one by alias; unset means embed
(nomic-embed-text v1.5), which is what every base built so far was indexed with.
fxlla pull embed-qwen3 --quant Q8_0 # multilingual, 1024-dim, 32k context
export FXLLA_EMBED_MODEL=embed-qwen3
fxlla kb reindex docs # re-embed: widths must matchVectors from two models are not comparable, so a base built with one model has
to be re-embedded before another can search it. fxlla kb reindex does that from
the chunk text already in the store, without re-reading the sources, in a single
transaction: an interrupted run changes nothing.
Rather than guess which model is better, measure. fxlla kb eval scores
retrieval over a golden set of questions against this repository's own
documentation and reports recall@1, recall@k, MRR and query latency:
fxlla kb eval # the model currently configured
FXLLA_EMBED_MODEL=embed-small fxlla kb evalThe corpus is live documentation, so editing it moves every score. Each run
prints a fingerprint of the corpus it used, and two runs are comparable exactly
when those match. CHANGELOG.md and docs/JOURNAL.md are left out of it on
purpose: they are where results get written down, so including them made every
recorded number stale the moment it was recorded.
A search spawns the embedding server, uses it, and stops it. That costs about
0.2s. Leave a server running on FXLLA_EMBED_PORT and fxlla kb reuses it
instead, which brings a query to roughly 0.09s and is worth doing if an agent
retrieves often:
llama-server --embeddings -m <embed model>.gguf --host 127.0.0.1 --port 8090 &Do not pass --pooling: every embedding GGUF declares its own pooling type and
llama.cpp honours it. Forcing mean is right for nomic and wrong for bge (CLS)
and qwen3-embedding (last token), and a badly pooled vector is still a vector,
so nothing later would report it.
fxlla never stops a server it did not start, so use fxlla kb stop when you want
it gone. The server has to hold the model the base was built with. fxlla kb
asks it which one it loaded and refuses on a mismatch, because two models can
share a width: nomic and embeddinggemma are both 768-dim, qwen3-embedding and
bge-large both 1024, and at equal width nothing further down can tell them
apart.
Search uses a brute-force cosine scan by default, which is fine up to a few thousand chunks. For larger knowledge bases, opt into a vector index:
export FXLLA_KB_INDEX=1 # sqlite-vec KNN index, built on demand
fxlla kb search docs "how does eviction work"With the index on, fxlla kb runs under uv run --with sqlite-vec (no
persistent install); it falls back to the scan automatically when the extension
is unavailable, and the results are identical either way. The MCP server picks up
the same setting, so rag_search uses the index too; re-run
fxlla kb wire-opencode after changing FXLLA_KB_INDEX to refresh the
registration.
Index a codebase and navigate it structurally: where a symbol is defined, where
it is referenced, which functions call it, and the transitive blast radius of a
change. Python is parsed with the standard library ast; JavaScript, TypeScript,
Go, Rust, Java, C/C++, and Ruby with tree-sitter. Symbols are stored in an
embedded KuzuDB graph, so change-impact is a single Cypher variable-length path,
and def/callers/impact resolve by name across languages. fxlla graph runs
the backend under uv run --with kuzu==0.11.3 --with tree-sitter --with tree-sitter-language-pack (uv is already required); set FXLLA_GRAPH_PYTHON to
use your own interpreter that has those packages.
KuzuDB is pinned to 0.11.3: its upstream was archived after the October 2025
Apple acquisition, and 0.11.3 is the final release. It installs and runs fine;
the pin keeps it reproducible. Override FXLLA_GRAPH_PYTHON if a maintained fork
or successor is adopted later.
fxlla graph index . # index a directory (Python, JS/TS, Go, ...)
fxlla graph def _db # where a symbol is defined
fxlla graph callers _db # which functions call it
fxlla graph refs chunk_text # where it is referenced
fxlla graph impact chunk_text # transitive callers (blast radius)Expose it to your tools as an MCP server (find_definition, find_references,
find_callers, find_impact, list_unused):
fxlla graph wire-opencodeGenerate images, short videos, and speech locally. Images use the mflux-cv
toolchain, video uses ltx-2-mlx (LTX-2.3), speech uses mlx-audio (Chatterbox):
fxlla media models # supported image models
fxlla media image "a red sailboat at sunset" --seed 42
fxlla media video "a sailboat at sunset, gentle waves" \
--frames 49 --width 768 --height 512 --frame-rate 24
fxlla media voice "Hello from a local voice." --ref reference-voice.wav
fxlla media edit "make it snow" --image photo.png # instruction-based edit
fxlla media upscale --image photo.png --scale 2x # diffusion super-resolutionfxlla media models lists what each model supports, because they differ: only
some take a LoRA, an init image, or a control input. Asking for an option a
model cannot take is refused by name, with the models that can.
Ideogram 4 accepts a JSON caption instead of prose, which is how you say where
things go rather than hoping. Elements are typed obj or text, and a bbox is
[y_min, x_min, y_max, x_max] - Y first - as integers in a 0..1000 space.
Hex colors must be UPPERCASE. fxlla validates all of it before the render
starts, so a mistake is named instead of arriving as a schema warning minutes
in; a plain prose prompt still works and is left alone.
cat > poster.json <<'JSON'
{
"high_level_description": "A vintage poster of a chrome lighter, flame lit.",
"style_description": {
"aesthetics": "mid-century travel poster, flat vector shapes",
"lighting": "warm rim light from the flame, soft vignette",
"medium": "screen print with limited ink layers",
"art_style": "retro Swiss poster design, clean negative space",
"color_palette": ["#0D3B45", "#F2B134", "#E8E3D3"]
},
"compositional_deconstruction": {
"background": "flat deep teal field with a faint radial glow",
"elements": [
{"type": "text", "bbox": [80, 120, 240, 880], "text": "ZIPPO",
"desc": "condensed display type, letter-spaced",
"color_palette": ["#E8E3D3"]},
{"type": "obj", "bbox": [300, 330, 760, 670],
"desc": "chrome lighter standing upright, lid open, tall flame"},
{"type": "text", "bbox": [820, 200, 900, 800], "text": "BUILT TO LAST",
"desc": "small uppercase tagline, wide letter spacing",
"color_palette": ["#F2B134"]}
]
}
}
JSON
fxlla media image "$(cat poster.json)" --model ideogram4 --aspect 3:4fxlla media loras # what you can apply
fxlla media image "a portrait" --lora style.safetensors,0.8 --lora-style portrait
fxlla media image "a street" --model controlnet --control edges.png
fxlla media image "a room" --model z-controlnet \
--control pose:pose.png:0.8 --control depth:depth.png # controls stack
fxlla media image "a hallway" --model depth --init-image room.png --save-depth-map--save-depth-map prints the derived map's path on a second line, so a depth
pass can feed a later control pass. Chaining across steps is the caller's job:
fxlla exposes each step and its outputs, and deliberately has no agent loop.
Generated audio is checked for content, not just for a valid header, because a
TTS run that emits silence writes a perfectly well-formed WAV. The checks flag
silence, a constant waveform, a large DC offset, and a clip that is silent for
almost its whole length; thresholds sit far below real speech (which measures a
peak near 0.95 against a 0.005 limit), so a render you wanted is not rejected.
Images are checked only for sane dimensions - a blank but well-formed image is not
detected. Video always gets container checks (present, plausibly sized, a real
MP4); its stream is inspected only when ffprobe is installed. Frame count and
length are yours to choose, so a one-frame
or fraction-of-a-second clip is accepted. If a check ever rejects something you
wanted, pass --skip-quality or set FXLLA_MEDIA_SKIP_QUALITY=1.
Point FXLLA_MFLUX_BIN_DIR at the directory holding the mflux-* image CLIs
when they live in a venv off PATH (unset means PATH, where fxlla setup --media puts them). Point FXLLA_VIDEO_BIN at your ltx-2-mlx binary and
FXLLA_VOICE_PYTHON at an
interpreter with mlx-audio (both usually live in a project venv), and
FXLLA_MEDIA_HF_HOME at the weight cache. fxlla doctor reports which of these
are ready. Before a job, media generation asks a running gateway to free its
resident models so a heavy render does not exceed the GPU limit (opt out with
--keep-models). Expose them as MCP tools (generate_image, generate_video,
generate_speech, edit_image, upscale_image):
fxlla media wire-opencodeThe toolchains download their weights from Hugging Face on first use, which can
be tens of gigabytes in the middle of a render. fxlla media weights shows the
catalog (config/media.conf) with sizes and what is already cached, and you can
fetch ahead of time:
fxlla media weights # alias, kind, size, cached or missing
fxlla pull media:z-image-turbo # pre-fetch one (about 33 GB)
fxlla pull media:ltx # video: LTX-2.3 plus its Gemma text encoderMedia weights go into the Hugging Face cache (FXLLA_MEDIA_HF_HOME, or the HF
default when unset) rather than FXLLA_STORE, because the toolchains resolve
them by repository id - the cache layout is what they look for. That also means
the Hugging Face CLI does the transfer, so the bandwidth cap does not apply to
media pulls. fxlla doctor reports how many catalog entries are ready.
Pre-fetching is optional. Skipping it is fine: the toolchain then downloads only the files it actually needs on first render, which for some repositories is much less than the whole thing.
Video can run for minutes. Add --async to any generator to get a job id back
immediately and poll it instead of blocking:
fxlla media video "a sailboat at sunset" --async # prints a job id
fxlla media jobs # all jobs, newest first
fxlla media job <id> # one job (add --json)
fxlla media cancel <id> # stop a queued or running job
fxlla media jobs --prune # drop finished records
fxlla media timings # what renders have taken hereA finished or failed job posts a desktop notification on macOS with what it
produced and how long it took (FXLLA_MEDIA_NOTIFY=0 to stop it). That is
there because an MCP tool call is request/response and an agent turn ends when
the model stops calling tools — nothing on the server side can reopen a turn to
announce a render, so an assistant can only tell you when you ask. If your
client can run work in the background and wake itself when it finishes (Claude
Code does; --async composes with it), use that instead and the notification
is redundant.
fxlla media timings is the measured record, computed from those jobs: median
seconds and seconds-per-megapixel per model and per video stage. Nothing in it
is estimated and nothing comes from other hardware, so a model with no runs
shows no number rather than a guess. It is also what the MCP publishes as
observed, which is where a step count stops being a useful proxy — krea2 and
z-image-turbo both run 8 steps and are 9x apart here.
Jobs run one at a time: renders share unified memory with the gateway's
models, so a second submission waits (queued) until the current one finishes.
State lives next to the outputs, in <media out>/jobs. Over MCP every render
goes through this queue and the tool returns a job id immediately, so a
render never holds the conversation open — the quickest one measured here takes
about a minute and a poster took eight, so there is nothing to be gained by
waiting. media_job_status answers instantly with the record or a
still-running line; list_media_jobs and cancel_media_job complete the set.
FXLLA_MCP_WAIT_S (default 0) can make a call wait for the result instead —
keep it under the calling client's timeout, because a call held open longer
than the client waits comes back as a timeout, and a timeout reads as a
failure: one model, unable to tell the two apart, submitted the same render
four times. async: false holds the call open until the file is written.
Wire every local tool into your client at once, and install the skills that tell a model when to use them:
fxlla wire-opencode --all # provider + rag, graph, and media MCP servers + skills
fxlla skills install # the skill pack for opencode and Claude Code
fxlla skills status # is each installed copy current, drifted, or missingThe skill pack (skills/) is guidance, not code: retrieve from a knowledge base
before answering, walk the code graph before editing, offer media generation,
and check availability and consent before a download. Registering a tool makes
it callable; the skills make a model reach for it at the right time. Install is
idempotent; restart the client to load them.
Install validates each skill's frontmatter first and refuses the whole run if one
is malformed, since a client silently ignores a broken skill and the only symptom
is a model that never reaches for the tool. It also removes skills it previously
installed that the repo no longer ships, so a renamed one stops advertising a tool
that changed - and it only ever touches directories it created, leaving skills
from other tools (and your own instructions entries) alone. fxlla doctor
reports whether the installed copies are current.
fxlla is the control plane. It serves an OpenAI-compatible endpoint that
opencode consumes as a local provider, alongside Claude and any other
provider. Claude Code is not modified. On top of that it ships local knowledge
bases, a multi-language code graph, and image, video, and speech generation,
all exposed
as MCP tools any client can call. See ROADMAP.md and docs/roadmap-remaining.md
for what is next.
All settings live in ~/.config/fxlla/config.env or the environment. See
config/config.env.example for the full list with comments.
- Core:
FXLLA_STORE,FXLLA_RATE_MBIT,FXLLA_HOST,FXLLA_PORT,FXLLA_DEFAULT_MODEL,FXLLA_KEEP_WARM,FXLLA_CTX,FXLLA_NGL,FXLLA_RAM_RESERVE_MB,HF_TOKEN. - Gateway and RAG:
FXLLA_GATEWAY_BUDGET_MB,FXLLA_BACKEND_PORT_BASE,FXLLA_EMBED_PORT. - Media:
FXLLA_MEDIA_MODEL,FXLLA_MEDIA_HF_HOME,FXLLA_MEDIA_OUT,FXLLA_VIDEO_BIN,FXLLA_MFLUX_BIN_DIR,FXLLA_VOICE_PYTHON,FXLLA_VOICE_REF,FXLLA_VOICE_LANG.
Edit config/models.conf: alias | repo | size | role | engine | note.
fxlla pull <org/repo> also works with any Hugging Face repository.
See AGENTS.md, MAINTAINERS.md, COLLABORATORS.md, and SECURITY.md.
Part of the HowDidTheCatGetSoFat community efforts.
MIT. See LICENSE.