11 · machine setup

Omarchy: darkmux on Linux

darkmux was built on macOS. This page is the path on Omarchy (Arch Linux) on an Apple Silicon Mac running Asahi: build from source, serve a model on the Apple GPU without LM Studio, and set up what Linux doesn't give you for free. Linux support ships in darkmux releases from v5.3.0, built from source (there is no Linux package yet, #3274); the last section says what doesn't work yet.

Build and install

There is no Homebrew here, so darkmux builds from a release tag. Omarchy ships mise for the Rust toolchain:

mise use -g rust@1.97.1
cargo install --git https://github.com/kstrat2001/darkmux --tag v5.4.0 --locked darkmux
ln -sf ~/.cargo/bin/darkmux ~/.local/bin/darkmux   # ~/.cargo/bin is not on Omarchy's PATH
darkmux --version
darkmux init

To update, re-run the cargo install line with the newest tag from the releases page. A ✱ after the commit in --version is harmless: cargo's checkout looks modified to the build's git probe. With no LM Studio, init can't fill in a worker model; the next section adds one.

A local model without LM Studio

omarchy-mlx runs Apple's MLX on the Mac's GPU through Mesa's Honeykrisp Vulkan driver and installs mlx_lm.server, an OpenAI-compatible chat server. darkmux uses it as an OpenAI-compatible engine, the machine's default engine once you set it below: darkmux adopts the server you started, sends it requests, and leaves loading to the server, which loads whichever model a request names. While it reads a prompt, mlx_lm.server reports how far it has got, so the scope shows a bar under the brain and the line says the percentage and the time left, on this machine's viewer and on every other viewer in the fleet. With a prompt cache hit it counts only the part of the prompt the cache didn't hold.

Install omarchy-mlx

The Omarchy menu's entry (Install > AI) refuses chips it hasn't been tested on; the project's own installer warns instead and carries on. Read the script before running it:

sudo pacman -S --needed lapack blas openblas
curl -fsSL https://raw.githubusercontent.com/joshuaswarren/omarchy-mlx/main/install.sh -o mlx-install.sh
less mlx-install.sh
bash mlx-install.sh

It ends with a smoke test on your GPU. If a download fails partway, run it again.

Pick a model that fits

The model shares memory with everything else on the machine, and its KV cache grows with the prompt; darkmux's role prompts alone are about 3,500 tokens. The Asahi Vulkan driver also caps each process's GPU heap at half the RAM, and as the context fills it, generation slows sharply, then fails with VK_ERROR_OUT_OF_DEVICE_MEMORY. Pick a model that fits your RAM with room to spare. The examples below use the small mlx-community/Qwen3-1.7B-4bit; with more memory, go bigger. On an 8 GB machine even that model ran out of GPU memory partway through a 3,000-token prompt at the default heap, and finished 5,800-token prompts with the heap raised to 5 GB (HK_SYSMEM, under "Run it lean" below).

Serve it as a user service

mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/mlx-local.service <<'UNIT'
[Unit]
Description=mlx_lm.server on 127.0.0.1:8080
After=network-online.target

[Service]
ExecStart=%h/.local/share/mlx-omarchy/venv/bin/mlx_lm.server --model mlx-community/Qwen3-1.7B-4bit --host 127.0.0.1 --port 8080 --prefill-step-size 512 --prompt-cache-size 1 --decode-concurrency 1 --chat-template-args '{"enable_thinking":false}'
Restart=on-failure

[Install]
WantedBy=default.target
UNIT
systemctl --user daemon-reload
systemctl --user enable --now mlx-local
curl -s http://127.0.0.1:8080/v1/models
Keep the single quotes around the JSON. systemd strips double quotes from ExecStart; without the single quotes the server receives {enable_thinking:false} and exits. (enable_thinking: false turns off Qwen3's reasoning preamble, so a short answer stays short.)

Add an endpoint and a profile to ~/.darkmux/profiles.json:

"profiles": {
  "mlx-small": {
    "description": "Qwen3-1.7B on the local mlx_lm.server.",
    "models": [{ "id": "mlx-community/Qwen3-1.7B-4bit", "endpoint": "mlx-local" }]
  }
},
"endpoints": {
  "mlx-local": { "url": "http://127.0.0.1:8080/v1", "dialect": "chat-completions-max-tokens" }
}

darkmux's utility model (radio's routing seat, and compaction of long runs) defaults to an LM Studio model. Point it at the same server:

"internal": {
  "utility": { "id": "mlx-community/Qwen3-1.7B-4bit", "endpoint": "mlx-local", "n_ctx": 8192 }
}

If you have no LM Studio, remove the profiles init wrote for it (fast, balanced, deep) so darkmux doctor passes lms binary, and make the server this machine's engine, so darkmux stops asking LM Studio what is loaded:

darkmux config set default_engine mlx-local

A role with no tools now runs on the host against the endpoint, with no Docker and no LM Studio:

darkmux dispatch pr-reviewer --profile mlx-small "In two sentences: what is one risk of merging a change that adds a new required config field?"

Docker, for roles with tools

Every role with tools (coder, reviewer, lab workloads) runs in the darkmux-runtime container. Docker Desktop on macOS hides two differences that Docker Engine on Linux doesn't, and darkmux handles both:

darkmux never runs sudo, so the rest is yours:

sudo usermod -aG docker "$USER"                       # then log out and back in
sudo systemctl enable --now docker.service
sudo ufw allow in on docker0 to any port 8080 proto tcp
sudo ufw status verbose

Then serve the model on the bridge address (--host 172.17.0.1, Omarchy's Docker bridge) instead of 127.0.0.1, so only containers reach it, and point the endpoint at http://host.docker.internal:8080/v1. A role with tools compacts against the model's context window, which an OpenAI-compatible engine doesn't report, so the profile declares it: add "n_ctx": 32768 (or the model's real window) to the profile's model.

darkmux dispatch code-reviewer --profile mlx-small "Use the exec tool to run: echo hello-from-container. Then reply with the output in one sentence."

Keep the daemon running

darkmux serve runs the viewer (http://127.0.0.1:8765/), presence, and the flow sinks a fleet reads. On macOS, brew services keeps it up; on Omarchy it's a systemd user service:

cat > ~/.config/systemd/user/darkmux-serve.service <<'UNIT'
[Unit]
Description=darkmux serve daemon (viewer, fleet presence, flow sinks)
After=network-online.target

[Service]
ExecStart=%h/.cargo/bin/darkmux serve
Restart=on-failure

[Install]
WantedBy=default.target
UNIT
systemctl --user daemon-reload
systemctl --user enable --now darkmux-serve
journalctl --user -u darkmux-serve -n 20

The daemon reads config.json once at start: after a darkmux config set, run systemctl --user restart darkmux-serve.

Join a fleet

A fleet is a few of your machines on a Tailscale tailnet, streaming flow records to one hub's Redis. The full model is in the fleet guide; this is the Omarchy part.

Tailscale

Update the system first: a stale package database asks the mirror for a file it no longer has, and pacman -Sy alone leaves a partial upgrade, which Arch doesn't support.

omarchy-update                          # or: sudo pacman -Syu
sudo pacman -S tailscale
sudo systemctl enable --now tailscaled
sudo tailscale set --operator=$USER     # tailscale status / ping without sudo
sudo tailscale up                       # prints a login URL
tailscale ping <hub's tailnet address>

Name it for the OS

Asahi installs alongside macOS, and darkmux treats each install as its own machine (macOS by its IOPlatformUUID, Linux by /etc/machine-id). So a dual-boot laptop is two machines in the fleet, never online together. Give the Linux side its own name with omarchy in it, so each card says which OS it is; otherwise Tailscale names it <name>-1. The rest of this page calls it <runner> (for example laptop-omarchy). Showing a dual-boot laptop as one card is planned (#3212).

sudo tailscale set --hostname <runner>
darkmux config set machine_id <runner>

Point it at the hub

darkmux config set fleet.mode peer
darkmux config set redis.enabled true
darkmux config set redis.host <hub's tailnet address>
systemctl --user restart darkmux-serve
darkmux doctor
darkmux flow note --text "hello from omarchy"

The note appears in every fleet viewer, and the machine shows as a card with its name and hardware. In doctor, rows whose probes are macOS-only warn rather than pass, and a password-less, tailnet-only hub warns on redis config; both are expected.

The roster and the fleet token

Records reaching the hub isn't the same as this machine knowing the others. Its view lists only the machines in its roster, and it reads each peer's card from that peer's fleet listener, which refuses a request without the shared fleet token. Register the hub, and this machine, by tailnet name:

darkmux machine add <hub's machine_id> --address <hub's tailnet name> --description "fleet hub"
darkmux machine add <runner> --address <runner>

Linux has no Keychain, so the token goes in the daemon's environment, in an owner-only systemd drop-in (a Redis password the same way, as DARKMUX_REDIS_URL):

mkdir -p ~/.config/systemd/user/darkmux-serve.service.d && (umask 077; printf '[Service]\nEnvironment=DARKMUX_SERVE_TOKEN=<fleet token>\n' > ~/.config/systemd/user/darkmux-serve.service.d/fleet-token.conf) && systemctl --user daemon-reload && systemctl --user restart darkmux-serve
darkmux machine list

The hub's row then shows its live card and the HUB badge, and the daemon log has no "token is missing or does not match" refusals. With this machine's own fleet listener off, it reads other machines' cards and takes no work from them; taking work is in the fleet guide.

Open a runner's viewer from any tailnet device

The daemon listens on 127.0.0.1:8765 only. To open it from your phone or another machine, forward a tailnet-only port to it with systemd: nothing listens on Wi-Fi, and the daemon stays on loopback. Put this machine's tailnet address (tailscale ip -4) in ListenStream:

cat > ~/.config/systemd/user/darkmux-viewer-tailnet.socket <<'UNIT'
[Unit]
Description=darkmux viewer on the tailnet (:8090 -> 127.0.0.1:8765)

[Socket]
ListenStream=<tailnet address>:8090
FreeBind=true

[Install]
WantedBy=sockets.target
UNIT
cat > ~/.config/systemd/user/darkmux-viewer-tailnet.service <<'UNIT'
[Unit]
Description=Forward the tailnet viewer port to the darkmux daemon
Requires=darkmux-viewer-tailnet.socket
After=darkmux-viewer-tailnet.socket

[Service]
ExecStart=/usr/lib/systemd/systemd-socket-proxyd 127.0.0.1:8765
UNIT
systemctl --user daemon-reload
systemctl --user enable --now darkmux-viewer-tailnet.socket
sudo ufw allow in on tailscale0 to any port 8090 proto tcp

Then open http://<runner>:8090/ on any device with Tailscale connected. FreeBind lets the socket start before Tailscale has its address up. darkmux treats a request through this forward as remote, so its panels withhold this machine's addresses and paths; fleet cards and runs show in full.

Optional: run it lean

None of this is needed on a machine with plenty of RAM. But it's a nice Linux flex. On Apple Silicon the GPU shares memory with everything, including the desktop, and on Linux you can shut the whole desktop off and run the Mac as a lean headless box on your tailnet. You can't do that on macOS.

Trim the memory

mkdir -p ~/.config/systemd/user/darkmux-serve.service.d
printf '[Service]\nEnvironment=MALLOC_ARENA_MAX=2\n' > ~/.config/systemd/user/darkmux-serve.service.d/malloc.conf
systemctl --user daemon-reload && systemctl --user restart darkmux-serve
HK_SYSMEM is a ceiling, not a reservation. The GPU can then take memory the rest of the machine needs; raise it only on a machine doing little else.

Run it headless

The desktop (the compositor, the bar, every window) comes out of the same memory pool as the model. Lowering the resolution saves little; not running the desktop is what counts. Once the machine is on the fleet, its card and runs show in every other machine's viewer, so it doesn't need its own screen. Run these in order; the first two keep it on the fleet.

# 1. Stay awake with the lid closed, on external power (battery still suspends).
sudo mkdir -p /etc/systemd/logind.conf.d
printf '[Login]\nHandleLidSwitchExternalPower=ignore\n' | sudo tee /etc/systemd/logind.conf.d/30-headless-lid.conf
sudo systemctl restart systemd-logind   # may end the current desktop session

# 2. Keep user services (darkmux-serve, mlx-local) running without a login.
sudo loginctl enable-linger "$USER"
loginctl show-user "$USER" -p Linger      # Linger=yes

# 3. Check every piece starts at boot: each line should say "enabled active".
for u in tailscaled docker sshd; do echo "$u $(systemctl is-enabled $u) $(systemctl is-active $u)"; done
for u in darkmux-serve mlx-local; do echo "$u $(systemctl --user is-enabled $u) $(systemctl --user is-active $u)"; done

# 4. Reach it over the tailnet with Tailscale SSH: no keys to copy, works from a phone.
sudo tailscale set --ssh
ssh <you>@<runner>                       # from any tailnet device

A unit that says transient in step 3 was made with systemd-run and won't survive a reboot; write it as a unit file. Depending on your tailnet's access policy, Tailscale SSH may print a login.tailscale.com link to approve now and then. Plain OpenSSH works too; open port 22 only on the tailnet (sudo ufw allow in on tailscale0 to any port 22 proto tcp) and use keys, not passwords.

Then turn the desktop off. Omarchy logs you straight into Hyprland (SDDM autologin), so logging out doesn't help; stop the display manager from a text console instead. Press Ctrl+Alt+F3, log in, and run:

sudo systemctl stop sddm       # ends Hyprland; user services keep running (linger)
free -m                        # the memory the desktop was holding is back
sudo systemctl start sddm      # the desktop again, for this boot

# To boot without the desktop every time:
sudo systemctl set-default multi-user.target    # boot to a text console
sudo systemctl set-default graphical.target     # undo

# The text console never blanks, so turn the panel off with the lid open (no sudo):
brightnessctl -d apple-panel-bl set 0
brightnessctl -d apple-panel-bl set 50%

Anything running inside the desktop session ends with it; run terminals and agents in the text console or over SSH instead.

What does not work yet