06 · machine setup

Join a worker peer

Connect another machine on your tailnet to an existing darkmux hub: many machines, one development environment. This is a clean-machine, brew-only walkthrough: every command after brew install should work as written. Start small (an observability peer you dispatch to directly), and graduate to a full fleet worker when the hardware's ready.

Don't have a hub yet?

A peer connects to a hub, the always-on machine running Redis. If you haven't set one up, do that first: set up a secure hub. It's a short, secure, copy/paste recipe (password-less Redis bound to your tailnet, never the public internet). Come back here once darkmux doctor is green on the hub. You'll need one value from it: the hub's tailnet address (tailscale ip -4 on the hub).

Two postures: observability peer, or fleet worker

A peer can play two roles, and they're a progression. Start with the first; move to the second when you want it.

How the queue works. darkmux's work queue is a single global stream: the first available worker claims each job. It doesn't size-match jobs to machines, so a worker takes whatever comes next. That's why a fleet worker (Part 2) wants enough RAM for the kind of jobs you send the fleet, while an observability peer (Part 1) stays out of the queue and only runs what you start on it directly.

Part 1: Join as an observability peer

On the new machine: install Homebrew and join your Tailscale tailnet (tailscale up). If you'll run dispatches on the peer, also do the base getting-started setup (LMStudio + Docker). Then, confirming it can reach the hub:

# 0. Sanity: can this machine reach the hub over the tailnet?
#    HUB_IP is the hub's Tailscale address. Run `tailscale ip -4` ON THE HUB.
HUB_IP=<hub-tailnet-addr>
nc -zvw1 "$HUB_IP" 6379                            # expect "succeeded"

# 1. Install darkmux.
brew tap kstrat2001/darkmux
brew install darkmux

# 2. Declare this machine a peer and point it at the hub's Redis (password-less).
darkmux config set fleet.mode peer
darkmux config set redis.enabled true
darkmux config set redis.host "$HUB_IP"
darkmux config set machine_id mini-1              # operator-named; your call

# 3. Verify it reaches the hub.
darkmux doctor

darkmux doctor should read on flow sink health (a Redis-backed sink reaching the hub) and machine_id, and the verdict banner should land on ● ok (a on out-of-scope checks like "no profile loaded yet" is fine). Then prove the end-to-end write:

darkmux flow note --text "hello from mini-1"

Open the hub's live viewer (the hub itself, or https://<hub>.<tailnet>.ts.net/ if you set up Tailscale Serve). The note appears, tagged with this machine's id. That's the peer participating: its records flow to the hub, you see the whole fleet in one place.

No daemon required for this. Writing flow records to the hub happens in-process on every darkmux command; the peer does not need to run darkmux serve for its activity to show up in the hub's viewer. (The trade-off: without a local daemon, the peer won't appear in darkmux machine list --deep's live specs probe, which fans out to each machine's daemon. Its flow records are still in the hub viewer.)

Can I dispatch to this peer from another machine?

Short answer for Part 1: no, and it's worth understanding why before you reach for it.

A darkmux dispatch <role> --machine mini-1 … from another machine publishes the job to the global work queue. But a job only runs when some machine's claiming worker picks it up, and that worker only exists inside a running darkmux serve daemon with Redis configured. In Part 1 the peer isn't running that daemon, so it never claims. And because --machine is an advisory hint, not a pin, the hub's own worker (which IS running) would claim your --machine mini-1 job and run it on the hub.

So:

Run work on the peer (locally)

On the peer, dispatch with no --machine. It runs in the peer's own container, sized by you to fit the hardware:

# On the peer:
darkmux dispatch coder "implement the X feature"

This is the reliable way to use a small box: drive it directly (in person, or over SSH / its own Claude Code session), pick model profiles that fit its RAM, and let its activity stream to the hub for the fleet-wide view. The dispatch path is identical to the hub's (same container, same runtime loop, same flow records), just initiated on the machine that runs it.

Part 2: Run it as a fleet worker

To have a machine pull work off the fleet queue, so a dispatch from anywhere in the fleet can run on it, run the always-on daemon. The daemon spawns a worker that claims jobs from the global darkmux:work stream. The Part 1 config already points at the hub's Redis, so this is just starting the daemon:

# On the peer (Part 1 config already set):
brew services start darkmux                         # daemon + claiming worker

# Confirm it's serving + claiming:
darkmux doctor 2>&1 | grep -i "daemon reachable"   # ✓

The worker enables itself because Redis is configured, and shares a consumer group with every other worker on the fleet, so each job goes to exactly one of them. To take the machine back out of the pool, brew services stop darkmux: it reverts to a Part 1 observability peer (records still flow, it just stops claiming).

Because the queue is first-available, any worker can claim any job. Match a worker to the work you send the fleet: a small box is great for light dispatches, but a big-model job that lands on it will run out of memory. If you want a machine to take only the work you hand it, keep it a Part 1 peer and dispatch on it directly.

Register it in the hub's roster so it shows in machine list, and add the hub to the peer's roster:

# On the hub:
darkmux machine add mini-1 --address <peer-tailnet-addr>:8765
# On the peer:
darkmux machine add <hub-id> --address "$HUB_IP":8765
darkmux machine list

The big picture: how the fleet coordinates

Three layers, so the commands above make sense:

darkmux machine list --deep
MACHINE        ADDRESS                PROBE      RAM-FREE    OS              VERSION  MODELS
laptop         100.64.1.5:8765        ✓ 23ms     78 GB       macos aarch64   1.17.0   darkmux:qwen3.6-35b-a3b-mlx
studio         100.64.2.1:8765        ✓ 45ms     12 GB       macos aarch64   1.17.0   darkmux:qwen3-4b-instruct

Bounded at ~1s per peer on --deep; degraded peers render with specs? rather than failing the whole command. (Only machines running a daemon, meaning Part 2 workers or the hub, appear here; Part 1 observability peers show their records in the viewer but not in this live probe.)

Scope: single operator, multiple machines.

The trust boundary is your tailnet (Tailscale / WireGuard / ZeroTier), not enforcement in darkmux's code. The fleet's Redis connection carries no auth beyond what your mesh VPN + Redis bind already provide; provenance fields are operator-asserted. This works because everyone on the substrate is you. Multi-tenant deployment is explicitly out of scope; see DESIGN.md.

When things go wrong

The substrate degrades gracefully. Knowing the modes saves you confused debugging.

Hub drops off the network

The most common failure. Peers' flow writes fall back to local-file-only (records still land on disk per-machine; the Redis sink errors are logged + skipped). Cross-machine dispatch from a Part 2 worker bails loud. darkmux doctor reports the Redis sink unreachable. An open viewer tab's live tail retries for a bounded budget (~5s), emits a synthetic stream.error, and closes cleanly rather than spinning forever. Recovery: bring the hub back; the substrate self-heals on the next operation, no manual intervention.

"Why didn't my dispatch land where I expected?"

Because --machine is advisory, not a pin (see above). A routed dispatch emits a dispatch route flow record; filter the viewer for action: dispatch route. The payload shows target_machine and decision (pinned for an explicit --machine hint, local for a local fall-through). The substrate's reasoning is in the record, not hidden.

Two operators on the same machine

Out of scope. machine_id is per-machine, not per-user. If you need per-user provenance on a shared Mac, darkmux has outgrown its target: fork it.