02 · first inference

radio: darkmux in plain text

Interactive help and a plain-text way to run things. Ask radio anything about darkmux and a local model answers from the command tree and your machine’s state; say what you want done and it routes to one launchable mission config, or refuses rather than guess. Two model calls at most, no tools, nothing invented.

Ask it anything about darkmux

radio is interactive help. The answering seat is handed the whole command tree, every runnable verb with its options and one line each, plus your config, the mission board, and the session’s recent outputs. A question about darkmux is a lookup, not a guess.

$ darkmux radio "how do I see what models are loaded right now?"
radio: Run darkmux machine status. It will show exactly what models are loaded in LMStudio right now, grouped by ownership and matching profile.
$ darkmux radio "is my other Mac online?"
radio: Run darkmux machine list to check the fleet roster and per-machine reachability. I don't have live telemetry on your other Mac, but that command will tell you exactly whether it's online and reachable.
$ darkmux radio "how do I look at a past lab run?"
radio: To inspect a specific past run, use darkmux run inspect. You'll need the run ID; if you don't have it handy, run darkmux run list --kind lab to pull one from the recent history.

Verbatim, captured 2026-08-28 on an M5 Max: qwen3.6-35b-a3b answering, qwen3-4b routing, humor at the shipped default of 50 unless shown otherwise.

Since 5.0 there is a command for that first question. machine-status is a built-in mission config that runs darkmux machine status (in the editor panel, /mission launch machine-status), so a question like "which models are loaded?" has a command the router can send it to, and when it does, radio shows you the command it chose, asks before running it, and on a yes prints the loaded models instead of telling you which verb to run. Whether a given wording routes there is up to the routing model. It is read-only; nothing that unloads a model is launchable.

When it cannot reach a model

A setup problem is not a refusal. If the routing seat cannot run, radio says why once, names the fix, and exits 1, so a script can tell. No registry, a placeholder model, a model that is not downloaded, the LM Studio server down, and a missing lms each get their own message.

$ darkmux radio "what can you do?"
radio: could not reach a model. darkmux: no profile registry found. Looked in: /private/tmp/fresh-home/.darkmux.json /private/tmp/fresh-home/.darkmux/profiles.json /tmp/fresh-home/.darkmux/profiles.json /tmp/fresh-home/.config/darkmux/profiles.json Run darkmux init to bootstrap one, or create it manually (see profiles.example.json in the darkmux repo).

Captured from a home directory with no ~/.darkmux at all. Exit status 1.

When the model is busy

One LM Studio instance serves one request at a time. If the answering seat's model is the same instance a coder is using, a question asked mid-run would queue inside LM Studio, silently, until the call's 300-second ceiling. radio checks first. Just before sending, it reads what LM Studio reports for that instance (lms ps: idle, reading a prompt, generating a reply) and whether another darkmux process holds it in use. If either says busy, radio answers at once: which model, what LM Studio reports, and the run it is busy with when darkmux knows it, or the darkmux process holding it. When neither is found, radio says what it checked: no live run on that model in the last day of darkmux's records, and no darkmux process it can verify holding it. It does not guess whose work it is, and a run that has been going for longer than a day may not be named. The check reads facts, not timing, but it is a check made just before the send: work that starts on the instance in the moment between the two still makes the question wait. On the CLI the exit status is 1, since no answer was given; the same words appear in the editor panel.

The routing seat is the exception. It runs on the machine's utility model, which also compacts context for running workers, and a routing call that lands during a compaction waits behind it by design. After ten seconds of waiting radio says what LM Studio reports for the utility model, one of: other requests are waiting on it (for example a compaction), so the routing call is sharing it; it is busy with nothing waiting, which is the routing call itself; it is idle, so nothing is being served ahead of the call; or darkmux cannot tell (an older lms without a queue count, the model not listed, or lms ps unreadable). If another darkmux process has the utility model loaded, radio names it alongside that reading; having it loaded does not by itself put a request ahead of the routing call, so radio says the call may be sharing the model with that process only when LM Studio's queue count is not available. Then it keeps waiting. A seat on a hosted endpoint is not checked: the queue, if any, is on the provider's side.

Two ways around a busy answering seat: ask again when the run finishes, or point radio.answerer_profile at a profile whose model the run is not using.

The dial

The answering seat has a persona, RADIO, and one knob: radio.humor, 0 to 100. It ships at 50. Under about 40 the model answers plainly; 100 is the full voice.

$ darkmux radio "do you have a brain?"
radio: No biological brain. I'm a local inference engine running on your MacBook-Pro, backed by LMStudio and a handful of quantized models. No synapses, just tensors—processing your inputs on your hardware, not the other way around.
$ darkmux config set radio.humor 100
$ darkmux radio "do you have a brain?"
radio: Negative. I'm a local-AI console interface running on your hardware, backed by LMStudio and whatever models you've got loaded. No neurons required—just silicon, a config file, and a healthy respect for thermodynamics. If you need me to route commands or check the flow substrate, I'm on frequency.

Verbatim, captured 2026-08-28 on an M5 Max: qwen3.6-35b-a3b answering, qwen3-4b routing, humor at the shipped default of 50 unless shown otherwise.

Configuration

Two keys under radio in config.json; darkmux init writes them visible with their defaults. Each has an env override that wins live. The routing seat has no key: it runs on the machine's utility model, declared once as internal.utility in profiles.json (with its own n_ctx), the same model that compacts context for your workers. That model is never selectable for a task, and the router's calls show up in the fleet total as utility, never as runs.

KeyEnvDefaultEffect
radio.answerer_profileDARKMUX_RADIO_ANSWERER_PROFILEunsetProfile for the answering seat. Same fall-through via role_profiles.radio-host.
radio.humorDARKMUX_RADIO_HUMOR500 to 100, substituted into the persona. Out-of-range values are clamped when read.
$ darkmux config set radio.answerer_profile deep
$ darkmux config set radio.humor 80
$ darkmux config get radio.humor

The answering seat honors runtime.max_tokens_per_call as its per-call budget and otherwise uses 16,384; a reasoning model that spends the whole budget thinking gets reported as a failed answer, not a blank line. In the editor integration (darkmux acp) the host profile and humor are also session pickers (presets 10 / 50 / 75 / 100) that never touch config.json.

Where the answering seat comes from

Radio picks the seat in this order: the session's "radio host" choice, then this machine's own radio.answerer_profile, then its role_profiles.radio-host binding, then the fleet hub's default (fleet.defaults.radio.answerer_profile, set on the hub, always a <profile>@<machine> address), then the machine's default_profile. The hub's default is read from the hub's machine card over the verified fleet listener, never from Redis, and only from a card that declares fleet.mode hub. Each machine keeps the last copy it read and uses it when the hub cannot be asked. Every answer prints one line saying which it used, for example radio: answering seat: deep@studio, fleet default from the hub, last seen 3m ago; darkmux doctor shows the same resolution in its fleet defaults row.

Answer on another machine

The answering seat can run on a fleet peer. Write the profile as an address, <profile>@<machine>, on the machine you type darkmux radio on (the laptop):

darkmux config set radio.answerer_profile deep@studio

A role_profiles.radio-host binding takes the same address. darkmux config set refuses a malformed one. The question is routed on the laptop as usual, then the answering seat's request is submitted to the studio's fleet listener, the studio runs it on its own deep profile, and the answer comes back and is printed. The laptop needs no profile called deep. The busy check is the studio's: it applies its own busy policy when the job arrives, and the laptop does not look for a local instance. The studio has to trust the laptop for that profile and for the radio-host role:

darkmux machine trust macbook-pro --profiles deep --roles radio-host

If the studio refuses (it does not trust the sender, does not define deep, or is busy), radio prints the refusal naming deep@studio, then the plain routing refusal, and exits 1. It never answers on the laptop instead. The studio builds the persona itself, from its own radio-host role prompt (an override of that prompt on the laptop does not travel), with the humor and surface you set on the laptop, and runs one plain exchange with no tools, whether its deep is a local or a hosted profile. The per-call budget is the smaller of the laptop's runtime.max_tokens_per_call (else 16,384) and the studio's own, so the machine that runs the model keeps the last word on its limit. An address naming the machine you are on is a local seat: a hosted profile written that way still withholds grounding. A peer is one of your own machines, so the answer is grounded as on this machine: the config surface, mission board, and recent outputs go to the studio. If the studio's deep is itself a hosted endpoint, they go on to that endpoint from there. See the fleet guide for trust and the roster.

In your editor

The same two seats sit behind darkmux acp, an agent server for editors that speak the Agent Client Protocol. In Zed, register it once and the agent panel gets one slash command, /mission, with three verbs: /mission list lists every launchable config, /mission launch <config> [name=value ...] launches one (/mission launch review reviews this branch's committed changes), and /mission show <id> prints one mission's phases, runs and tokens, the same text as darkmux mission show <id>. Every config darkmux mission launch accepts is launchable from the panel, with the same inputs and the same refusals. A value with spaces goes in quotes: /mission launch pr-merge test_command="cargo test -p darkmux". Type without a slash and the message goes to the routing seat first, a small model that maps it onto exactly one launchable config or refuses; a refusal falls through to the answering seat with the session’s recent outputs in its grounding. The routing seat’s pick is never the consent to run it: the panel prepares the launch first and shows the command that will run (the same darkmux mission launch <config> ... line the terminal prints, with every param, including the ones made from the editor’s working directory) in a code block in Zed’s permission dialog, and only Allow runs it. Reject, cancel or no answer runs nothing, and the panel says it was not run. A repo with nothing to review is reported without asking. A /mission launch you type yourself is your own command and is not asked again. A gated step asks for permission in Zed’s own dialog before it runs. The host profile and humor are session pickers there, so you can try a different answering seat without touching config.json.

// Zed settings.json
"agent_servers": {
  "darkmux": {
    "type": "custom",
    "command": "darkmux",
    "args": ["acp"],
    "env": { "DARKMUX_ORCHESTRATOR": "zed" }
  }
}

If darkmux is not on the PATH Zed launches with, give command the full path ($(brew --prefix)/bin/darkmux for a Homebrew install). DARKMUX_ORCHESTRATOR stamps every flow record the session writes, so the viewer can tell editor-driven work from terminal work.

Add your own commands

radio never runs what the router chose without asking. It prepares the launch first, prints the command that will run in darkmux mission launch <id> --param ... form, with every param (for review, the diff_file, workspace and head_sha radio makes from the current directory; the first two are temporary files, and head_sha is a commit hash), and asks Run it? [y/N]; only y or yes runs it. Anything else, the end of input, or a Ctrl-C at the prompt runs nothing. With no interactive terminal (a script, a pipe) radio prints the command, says it was not run, and exits 1. For review the printed command is not runnable later as it stands, because the two temporary files are removed when radio exits: save a diff and a workspace spec of your own and pass them as --param diff_file=<path> and --param workspace=<path>; darkmux mission config show <config> lists what each input takes. A router argument holding a control character or an invisible formatting character (a bidi override, a zero-width space) is refused. A Ctrl-C at the prompt ends it within a moment and removes the temporary files. A step that asks for sign-off still asks again when the launch reaches it. The router picks from every launchable config, so the pick is a suggestion; your answer is the consent.

radio only routes to what is launchable. Two configs ship built in that a chat message can reach, review and the read-only machine-status; any mission config in ~/.darkmux/mission-configs/ joins the catalog and /mission list the moment the file exists, unless a user file is stale: darkmux mission launch refuses every launch while any of your mission configs carries a retired key, so the list and the catalog then show that refusal instead of configs, and darkmux doctor names the file, and the first sentence of its description (else its name) is what the router reads, so lead with one plain sentence about what it does. A config takes text after its id only if a task reads __panel_args__; otherwise pass declared inputs as name=value. The old per-config panel block is gone: a config still carrying one is refused, with a message naming /mission launch <id>. A step that changes state carries "gate": "operator" and waits for your confirmation, at the terminal or in the editor.

{
  "id": "pr-merge",
  "name": "PR Merge",
  "description": "Merge a pull request once every check is green.",
  "phases": [{ "id": "run", "tasks": [{
    "id": "merge-task", "description": "merge the named PR", "reads": ["__panel_args__"],
    "steps": [{ "id": "merge-step", "kind": "procedural.shell", "gate": "operator",
                "config": { "command": "gh pr merge $DARKMUX_STEP_INPUT___PANEL_ARGS__ --squash --delete-branch" } }]
  }]}]
}

__panel_args__ hands the step the text you typed after the config id (/mission launch pr-merge 2049 carries 2049; a routed "merge PR 2049" carries "PR 2049"), as an environment variable. darkmux ships no forge verbs of its own: GitHub, GitLab, and Perforce want different vocabularies, so gh lives in your config, never in darkmux. A graph with no model step runs in-process; one with a model step launches as darkmux mission launch <id> in a subprocess, so it is the same launcher the terminal uses. The full config shape is in Task/Step graphs.

What it is grounded in, and where that stops

The answering seat has no tools. Everything it can say was gathered before it ran: the command catalog, the command index, your config list, a mission-board summary, the session’s recent outputs, and one named mission’s detail when you ask about it. If radio.answerer_profile resolves to a hosted endpoint on this machine, only the catalog and the command index are sent; your config, board, and outputs stay on the machine. Keep the seat local if you want answers about this machine’s state.