Beta. The crawl mission and hooks shipped in 3.4.0 as a public beta. They run on darkmux's own code every day, but mission inputs and hook record shapes may still change between minor releases without a deprecation period.
A crawl (#1959) is a mission like any other: point it at source trees, name the rules to check, and a local model reads bounded slices of code and records each match with create_finding. Every accepted call becomes a finding you can list and show with no hook configured. Hooks (#2093) route matching flow records to a receiver you run, on loopback or your own tailnet, and nowhere else. darkmux is the worker, never the orchestrator: it never reads a tracker and never opens a PR. The two halves ship independently; this page walks both in the order you'd set them up.
A crawl's target is a workspace spec: a JSON file naming source trees, what to include and exclude, and which rules to check. Pass its path to --param workspace=. This one points at darkmux's own public repo:
{
"schema_version": "1.0",
"name": "darkmux-self",
"sources": [
{ "id": "darkmux", "git": "git@github.com:kstrat2001/darkmux.git", "ref": "main" }
],
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs", "**/*.md"],
"exclude": ["**/node_modules/**", "**/dist/**", "**/docs/demo/**", "**/target/**"],
"edges": [],
"rules": ["swallowed-error", "doc-contradicts-code"]
}
sources[]: git (cloned once into ~/.darkmux/workspaces/<name>/tree and reused at the same ref) or path (a local directory, used in place).include/exclude: glob lists narrowing the file set; each rule's applies_to narrows further.edges: dependency edges between sources, for edge-kind rules.rules: rule ids, resolved like roles: ~/.darkmux/rules/ first, then the built-ins.--dry-runNothing minted, nothing dispatched. Tracks --param rules= leaves out are gone from the graph, not greyed out:
darkmux mission launch crawl --param workspace=/path/to/darkmux-self.json --param rules=swallowed-error --dry-run
graph: 3 of 9 steps minted (6 left out by config)
graph:
phase Plan (1 task(s))
task Plan: Swallowed errors
step plan-swallowed-error-step [crawl.plan]
phase Crawl (1 task(s))
task Crawl: Swallowed errors role=crawler depends_on=["plan-swallowed-error"]
step unit-swallowed-error-step [crawl.unit]
phase Summarize (1 task(s))
task Summary
step summary-step [crawl.summary]
The Crawl phase declares one template per rule, grown into one task per planned unit at run time. A unit is one bounded dispatch's worth of work; the plan carries each one's token estimate.
workspace= takes a file path, not inline JSON.
Passing the JSON text itself fails with No such file or directory, quoting the JSON back inside the error. Write the spec to a file and pass its path.
A rule is a named property bound to files by glob, in the same registry shape roles use: built-ins at templates/builtin/rules/, overridable by id at ~/.darkmux/rules/<id>.json. Four ship today, one plan task each:
| Rule | Kind | What it looks for |
|---|---|---|
unnamed-predicate | site | A compound boolean condition inlined into a branch instead of named, so its reason is nowhere written down. |
swallowed-error | site | A catch/.catch(...)/void somePromise() site where the failure is neither rethrown, logged, nor checked by the caller. |
doc-contradicts-code | read | A doc comment or README claim the adjacent code visibly does not do. |
stale-consumer | edge | A consumer's pinned dependency range excludes a library version whose imported symbols changed shape. |
site rules run a regex prefilter first, so the model only reads flagged sites. read rules read whole files, grouped up to chunk_tokens. edge rules fire on an edge the workspace spec declares. The swallowed-error rule in full:
{
"id": "swallowed-error",
"kind": "site",
"title": "A failure is caught or discarded and nothing records that it happened",
"applies_to": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.mjs", "**/*.cjs"],
"exclude": ["**/node_modules/**", "**/dist/**", "**/build/**", "**/*.test.*", "**/*.spec.*"],
"prefilter": ["\\bcatch\\s*(\\(|\\{)", "\\.catch\\s*\\(", "^\\s*void\\s+[A-Za-z_$][\\w$.]*\\s*\\("],
"window": 30,
"match": "The site is a `catch` clause, a `.catch(...)` handler, or a `void somePromise()` statement, AND the failure that reaches it is neither rethrown, nor returned as an error value the caller checks, nor logged, nor counted, nor recorded anywhere the surrounding lines show.",
"no_match": "The handler rethrows or wraps and throws. The handler logs. A comment says why ignoring the failure is safe. Test code, generated code, or a `.d.ts`.",
"evidence": "The `catch`/`.catch`/`void` line, copied verbatim. `line` is where it appears.",
"why_hint": "Say what operation failed silently and what downstream code assumes about it."
}
match/no_match go to the model verbatim; evidence and why_hint shape what a finding must cite. Each rule is one crawl.plan task in crawl.json, so a rule you do not want tonight is one "enabled": false away. prefilter takes a list of regexes today; the {"command": …} shape for a linter emitting SARIF is reserved and refused by name until it ships.
darkmux doctor✓ rules 4 rule(s) loaded (4 built-in, no user tier)
A malformed user-tier file, an empty applies_to, or a site rule with no prefilter shows here as a named warning, not a silent skip.
Drop --dry-run to dispatch. This mints a real mission, clones or reuses the tree, and dispatches one unit per planned row through the same container path darkmux dispatch uses. A unit can run for minutes, so narrow the scope with --param rules=.
darkmux mission launch crawl --param workspace=/path/to/darkmux-self.json --param rules=swallowed-error
Generic mission and dispatch bookends wrap each unit:
mission start # operator-tier: the graph this run minted (and what it pruned)
phase start
step complete # one per rule: the plan it wrote
step start
dispatch start # local-tier: role=crawler, the assembled prompt, the model
dispatch.turn / dispatch.tool / dispatch.reasoning # the loop, repeated per turn
dispatch.rest # only when runtime.turn_delay_ms is set — see the knobs table below
dispatch complete # wall_ms, rests, total_turns, total_tools, total_tokens
step complete # the unit's typed outcome: findings, wall_ms, per-unit token counts
# ...repeated per planned unit...
mission close # units_completed/errored/skipped, stopped_by, tokens_per_hour
Real records from a completed crawl, scrubbed of local paths and the machine id:
{"action":"mission start","tier":"operator","mission_id":"crawl-1787979698-b5961a",
"payload":{"graph":{"tasks_in_config":9,"tasks_minted":3,
"pruned":[{"id":"plan-unnamed-predicate","reason":"not_selected"}]}}}
{"action":"dispatch.rest","tier":"local","mission_id":"crawl-1787979698-b5961a",
"payload":{"ms":2000,"turn":1,"rest_ms":2000,"rests":1,
"context":{"workspace":"darkmux-self","rule":"swallowed-error","unit":"u-0001"}}}
# a unit step's own output — a typed `crawl.unit-outcome`, hashed and stamped
{"kind":"crawl.unit-outcome","hash":"9f2c...","producer":{"mission":"crawl-1787979698-b5961a"},
"body":{"unit":"u-0001","rule":"swallowed-error","result":"stop",
"findings":2,"findings_rejected":2,"prompt_tokens":657998,"completion_tokens":11719,
"wall_ms":265649,"rest_ms":34000}}
{"action":"dispatch complete","tier":"local","mission_id":"crawl-1787979698-b5961a",
"payload":{"wall_ms":268865,"rest_ms":34000,"rests":17,"turn_delay_effective_ms":2000,
"exit_code":0,"result_class":"ok","total_turns":18,"total_tools":55,
"total_tokens":669717}}
{"action":"mission close","tier":"operator","mission_id":"crawl-1787979698-b5961a",
"payload":{"units_in_plan":2,"units_selected":2,"units_not_run":0,
"units_completed":2,"units_errored":0,"findings":2,
"wall_ms":349707,"tokens_per_hour":7875514,"stopped_by":"done"}}
wall_ms includes rest time; subtract rest_ms for model-only time. The close payload IS the run's own crawl.summary step output. --param rules= narrows the PLAN itself, not just which units dispatch, so units_in_plan/units_selected reflect the narrowed scope here rather than the full rule registry's unit count — and units_not_run is 0 for a run that reached every unit it planned.
The runtime tool is create_finding (renamed from report_finding on 2026-09-03). Each accepted call writes one record at ~/.darkmux/findings/<dispatch>/<seq>/finding.json: the context the crawl supplied (mission, unit, rule, source, sha) plus the model's emission verbatim. darkmux never interprets the emission and never rewrites the record.
finding list [--mission <id>|--dispatch <id>|--rule <id>] [--json] reads the store, one row per record with a truncated preview of the emission:
$ darkmux finding list
crawl-crawl-1788400556-b1119b-u-0001/1 2026-09-03T01:59:29Z crawler (qwen3.6-35b-a3b-turboquant-mlx) [mission=crawl-1788400556-b1119b unit=u-0001 rule=unnamed-predicate]
{"evidence":" const playhead = transport.active && transport.scrubbed && transport.t < transport.tM…
crawl-crawl-1788402801-729335-u-0004/1 2026-09-03T02:42:16Z crawler (qwen3.6-35b-a3b-turboquant-mlx) [mission=crawl-1788402801-729335 unit=u-0004 rule=unnamed-predicate]
{"evidence":" const pending = open && (!daysQuery.data || !missionsQuery.data);","file":"/workspace…
# ...6 more rows...
8 finding(s) in ~/.darkmux/findings
finding show <key> prints one record whole. This one was captured before the 2026-09-03 rename, so its tool line still reads report_finding; a fresh dispatch writes create_finding there.
$ darkmux finding show crawl-crawl-1788400556-b1119b-u-0001/1
finding crawl-crawl-1788400556-b1119b-u-0001/1
dispatch crawl-crawl-1788400556-b1119b-u-0001
seq 1
recorded 2026-09-03T01:59:29Z
tool report_finding
mission crawl-1788400556-b1119b
phase crawl-1788400556-b1119b-crawl
step crawl-1788400556-b1119b-crawl-task-001-step-0001
proposer crawler (qwen3.6-35b-a3b-turboquant-mlx) on MacBook-Pro
context {"workspace":"darkmux-ui","source":"darkmux","sha":"20c77504...","rule":"unnamed-predicate",
"rules":["unnamed-predicate"],"unit":"u-0001","model":"qwen3.6-35b-a3b-turboquant-mlx",
"locality":"local","profile":"balanced"}
emitted
{
"evidence": " const playhead = transport.active && transport.scrubbed && transport.t < transport.tMax ? transport.t : null;",
"file": "/workspace/darkmux/ui/src/App.tsx",
"line": 206,
"pattern": "unnamed-predicate",
"why": "The condition combines THREE operands with `&&` to express a \"playhead readiness\"
concept — whether there's an active, scrubbed playhead with valid time. Proposed
extraction: `isPlayheadReady({ active, scrubbed, t, tMax })`."
}
finding sync [--since YYYY-MM-DD] replays the flow stream into the store for anything the live tailer missed (an older binary, a killed process). It is idempotent, and it skips records written before FLOW 1.33.0, which carry no emission.
The store is built from the same dispatch.tool record hooks match on: payload.tool_name, payload.ok, payload.emitted (the model's object, verbatim), and payload.emit_seq (1-based within the dispatch). The same finding as above; captured before the rename, so payload.tool_name still reads report_finding.
{"ts":"2026-09-03T01:59:29Z","action":"dispatch.tool","tier":"local","handle":"crawler",
"mission_id":"crawl-1788400556-b1119b","session_id":"crawl-crawl-1788400556-b1119b-u-0001",
"model":"qwen3.6-35b-a3b-turboquant-mlx","machine_id":"MacBook-Pro",
"payload":{"tool_name":"report_finding","emit_seq":1,"ok":true,
"emitted":{"evidence":" const playhead = transport.active && transport.scrubbed...",
"file":"darkmux/ui/src/App.tsx","line":206,"pattern":"unnamed-predicate",
"why":"The condition combines THREE operands with `&&`..."},
"context":{"workspace":"darkmux-ui","source":"darkmux","sha":"20c77504...",
"rule":"unnamed-predicate","unit":"u-0001"}}}
A hook transform composes a receiver's payload from the two halves: payload.context for provenance, payload.emitted for the model's claim. Trimmed from a real adapter:
# tracker-item.jq
def prog: (.payload.context.workspace // "crawl");
def unit: (.payload.context.unit // "u-0000");
def blob: (.payload.emitted
| if type == "object" then
(to_entries | map("\(.key): \(.value | if type == "string" then . else tojson end)") | join("\n"))
else tojson end);
{
summary: "\(prog)-\(unit)-\(.payload.emit_seq // 0)",
description: ("rule: \(.payload.context.rule // "?")\nmodel: \(.model) emitted_at: \(.ts)\n\n" + blob),
labels: ["kind:finding", "rule:\(.payload.context.rule // "?")", "crawl:\(prog)"]
}
A mod is how a finding could change: a kit (a diff, a sentence, a config value, whatever the proposer chose) that darkmux stores opaquely under its own key at ~/.darkmux/mods/<key>/. A mod may name several findings with --for, and a finding may attract several mods; finding show lists the mods that name it. The CLI and the runtime's create_mod tool are both producers.
darkmux mod create --by <actor> [--for <finding>]... [--kit <file>|-] [--attach <path>]...
darkmux mod list [--for <finding>] [--mission <id>] [--json]
darkmux mod show <key>
darkmux dispatch <role> --finding <key> --mod <key> (both repeatable) appends those stored records to the brief verbatim; a mod's attached files are mounted read-only at /darkmux-mods/<key>/attachments/.
Create mods, off by default. crawl.json declares a fourth phase whose one task grows a coder dispatch per finding, each handed that finding's record and asked to record its change with create_mod. Copy the config to ~/.darkmux/mission-configs/crawl.json and set "enabled": true on that task to turn it on; a key naming no stored record refuses the step rather than dispatching blind.
Both stores relocate with DARKMUX_FINDINGS_DIR / DARKMUX_MODS_DIR; see ENVIRONMENT.md.
Hooks are a FlowSink: match a flow record against operator rules and POST the match to a receiver you run. A rule's http target must be loopback (127.0.0.1/[::1]/localhost) or a Tailscale address (100.64.0.0/10, or a host ending in .ts.net), over plain http://; anything else is refused at load and again at every POST. darkmux doctor and flow status show each rule's resolved target kind (loopback/tailnet).
config.hooks, same visible-defaults shape as redis/audit. A hooks config written before 2026-09-03 that still names report_finding needs updating to create_finding:
{
"hooks": {
"enabled": true,
"rules": [
{ "match": { "action": "dispatch.tool", "payload.tool_name": "create_finding", "payload.ok": true },
"http": "http://127.0.0.1:8790/events" },
{ "match": { "action": "step *" },
"http": "http://127.0.0.1:8790/events" },
{ "match": { "action": "mission *" },
"http": "http://127.0.0.1:8790/events" }
]
}
}
A rule's match ANDs every key it names. Top-level fields: action, session_id, mission_id, machine_id, category, level; a trailing * matches any action with that prefix. A "payload.<dotted.path>" key is an exact scalar match inside the record's payload, which is how you subscribe to one tool call's outcome. A key the payload doesn't carry never matches.
hook.fired / hook.failedEvery delivery attempt emits its own flow record: hook.fired on a 2xx, hook.failed once bounded retries give up (a permanent 4xx, a redirect, or a receiver that stays down). Both carry delivery_id, the same id the HTTP request carried.
{"action":"hook.fired","source":"hook","payload":{"rule_index":1,
"target_host":"127.0.0.1:8790","delivered_action":"step start","attempt":1}}
A receiver that answers 2xx with {"rejected": N} gets that count stamped on the record as payload.receiver_rejected; the delivery still counts as consumed (at-least-once).
| Header | Value |
|---|---|
X-Darkmux-Delivery | A UUID-shaped id derived from the outbox line, so every retry of the same line carries the same id; also stamped as delivery_id on hook.fired/hook.failed. |
X-Darkmux-Event | The record's action. |
X-Darkmux-Machine-Id / X-Darkmux-Machine-Uid | The machine that produced the record. |
X-Darkmux-Sender | The machine POSTing, which differs from the producer when a hub relays another machine's record. |
X-Darkmux-Timestamp | Unix milliseconds at send time. |
X-Darkmux-Signature | Only when the rule names a signing_secret_keychain_item: sha256=<hex HMAC-SHA256> over "<timestamp>.<raw body bytes>". |
The secret never sits in config.json; the rule names a macOS Keychain item, or DARKMUX_HOOK_SECRET_<rule-index> carries it on any platform and wins when set. darkmux doctor warns when a tailnet target has no secret.
{
"hooks": {
"enabled": true,
"rules": [
{ "match": { "action": "step *" },
"http": "http://100.64.1.2:8790/events",
"signing_secret_keychain_item": "darkmux-hook-crawl-tracker" }
]
}
}
On the sending machine: security add-generic-password -a $USER -s darkmux-hook-crawl-tracker -w, then paste the shared secret.
darkmux flow statusCaptured before the 2026-09-03 rename (rule #0 would now read create_finding), Redis lines trimmed:
darkmux flow status — ⚠ warn
schema: 1.28.0
composition: Tee([LocalFile, Redis, Hooks])
...
Hooks
enabled: true
outbox_dir: ~/.darkmux/hooks
#0: action=dispatch.tool, payload.ok=true, payload.tool_name="report_finding" -> http://127.0.0.1:8790/events
undelivered: 0
last delivery: 2026-08-29T05:04:41Z
last drainer heartbeat: 2026-08-29T15:06:14Z
#1: action=step * -> http://127.0.0.1:8790/events
undelivered: 0
#2: action=mission * -> http://127.0.0.1:8790/events
undelivered: 0
Undelivered count, last delivery, and the drainer's heartbeat tell "nothing to send" from "stuck."
darkmux flow drainFlushes every outbox now instead of waiting for the background drainer:
darkmux flow drain (all rules) — delivered: 0, failed: 0
--file <path> --to <url> drains a stray outbox file whose rule was removed; darkmux doctor names any it finds.
Any process that accepts a POST and returns 2xx works:
import http from "node:http";
http.createServer((req, res) => {
if (req.method !== "POST") { res.writeHead(405).end(); return; }
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
const record = JSON.parse(body);
console.log(`[${record.action}] ${record.mission_id ?? record.session_id ?? ""}`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ rejected: 0 }));
});
}).listen(8790, "127.0.0.1", () => {
console.log("listening on http://127.0.0.1:8790/events");
});
node receiver.mjs, point a rule's http at http://127.0.0.1:8790/events, and every match prints as it lands. The {"rejected": N} body is optional.
A transform is a jq filter run in-process (jaq) at delivery time: record in, request body out. It has no filesystem, network, or Keychain access; only the destination (http + headers, or file) touches a credential. The value is a bare filename resolved inside ~/.darkmux/hooks/adapters/, read once at load. The URL policy is unchanged: a tailnet-reachable tracker works, a public https:// endpoint is still refused.
{ "match": { "action": "dispatch.tool", "payload.tool_name": "create_finding", "payload.ok": true },
"http": "http://100.64.1.2:8080/rest/api/3/issue",
"headers": {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": { "keychain_item": "darkmux-hook-jira" }
},
"transform": "jira-issue.jq" }
A headers value is sent as-is, or {"keychain_item": "..."} resolves a Keychain item holding the complete header value (Basic ..., Bearer ...) at delivery time. Resolved values print as "<redacted>" everywhere else. attribution_headers: false drops the X-Darkmux-* set for a receiver that rejects unknown headers.
~/.darkmux/hooks/adapters/jira-issue.jq, reading the emission from payload.emitted:
{
fields: {
project: { key: "OPS" },
summary: ("crawl finding: " + .payload.context.rule),
description: {
type: "doc", version: 1,
content: [{ type: "paragraph",
content: [{ type: "text", text: (.payload.emitted.evidence // "") }] }]
},
issuetype: { name: "Bug" }
}
}
A transform must produce exactly one JSON object (the body, compact) or one string (raw bytes). Anything else, including a syntax error, a timeout, an oversize output, or zero or several outputs, is terminal for that line: quarantined, never retried, with an excerpt on hook.failed. Bounds: hooks.jq_timeout_ms (5000) and hooks.jq_max_output_bytes (1048576).
jq -c 'select(.payload.tool_name=="create_finding")' ~/.darkmux/flows/<date>.jsonl | head -1 | jq -f ~/.darkmux/hooks/adapters/jira-issue.jq.file transport, no network. Name file instead of http (exactly one of the two). Each match writes one JSON file, {delivery_id, target_would_be, headers, body} with secrets redacted, and emits hook.dry_run instead of hook.fired.
{ "match": { "action": "dispatch.tool", "payload.tool_name": "create_finding" },
"file": "~/darkmux-hook-dryrun",
"transform": "jira-issue.jq" }
darkmux doctorA rule with a transform shows the adapter name, whether it parses, and its content hash. A missing or unparseable adapter disables that rule only; a bad http URL still refuses the whole sink.
#0: payload.tool_name="create_finding" -> http://100.64.1.2:8080/rest/api/3/issue [tailnet, unsigned], transform: jira-issue.jq (sha256:a1b2c3d4e5f6a7b8) (undelivered: 0)
delivery_id into a searchable field, but check-then-create is a second request, beyond a single jq filter.jq; delivery runs jaq. Test against jaq if a filter leans on an obscure builtin.The review mission config runs the same blocks against a pull request's diff, and it splits the work at a measured line. Detection stays local — a small seat reads one hunk against one rule and calls create_finding. Proposal does not. Measured on 2026-09-05, three local coder seats wrote an applying unified diff 1 of 4, 0 of 3 and 1 of 10 times; a clean-context frontier session given the same instructions wrote 4 of 4. So mod creation moved to the frontier — through the hook that already existed, because darkmux itself never calls a frontier model.
The loop, end to end:
unit dispatch --create_finding--> finding store
\--> hooks sink --> your receiver / outbox
|
your orchestrator session reads the match
|
/darkmux-mod-create --> subagent writes the diff
|
darkmux mod create --for <key>
|
create-mods waits --> mods.gate applies + tests --> inline suggestion
Config is operator state — darkmux never writes this for you. Add it to ~/.darkmux/config.json:
{ "match": { "action": "dispatch.tool", "payload.tool_name": "create_finding", "payload.ok": true },
"http": "http://127.0.0.1:8790/events" }
The finding key is not a field on the record; it is session_id + "/" + payload.emit_seq, which is exactly how the store names its directories. The bundled darkmux-mod-create skill carries the monitor recipe, including which file to watch: the day's flow file under ~/.darkmux/flows/ is the durable source, while a rule's .outbox.jsonl is a delivery queue whose already-delivered prefix is reclaimed once it passes 8 MiB.
review's create-mods phase dispatches nothing. Its first step is a bounded shell poll of darkmux mod list --for <key>, on a ~5s cadence, bounded by the mod_wait_seconds input.
mod_wait_seconds defaults to 0, which means do not wait — a self-hosted runner has no orchestrator session to receive the hook and no frontier seat, so a per-finding wait there would be pure wall-clock. The step completes at once, the gate finds no mod, and every finding delivers as a question.--param mod_wait_seconds=<N>, watch the stream, and record each mod as its finding lands. A gate-passed unified-diff kit renders as a one-click GitHub suggestion; a finding you decline still delivers, as the question it always was.{"waited": true, "found": false, …}; the gate records its ordinary no-mod skip and the run stays Clean. Reading a finding and writing nothing is the correct answer for a search-form finding or one that does not hold, so it must not be the thing that marks a run Degraded. Only a non-numeric mod_wait_seconds and a genuine infra failure error the step.Keep N under runtime.step_command_timeout_seconds (default 600). That bound governs every step command and is the outer bound here — if it is the smaller of the two it kills the poll first, and the step errors naming it rather than naming your input.
The hook needs a session watching it. A self-hosted runner has none, so the wait above is the wrong instrument there — and the finding that moved this work off the local tier was about the tier of the seat, not about who typed the command. A frontier-class hosted endpoint is a seat a runner can staff. So review's create-mods phase ships a second template, create-mod-dispatch, off by default: a coder dispatch carrying the same create-mod message crawl.json carries, byte for byte, aimed at an endpoint profile.
The opt-in is two fields in your own copy at ~/.darkmux/mission-configs/review.json — "enabled": true on create-mod-dispatch, "enabled": false on create-mod. The two excludes each other: enabling both is a validation error naming both, because they staff one slot. The dry-run graph shows whichever is live; the other is pruned at mint, never drawn gray. Disabling one side silences the conflict, not the whole check — an excludes entry naming a task that does not exist stays an error even in a disabled template, because that is the typo that costs nothing until the day you enable it.
darkmux mission launch review --dry-run \
--param workspace=ws.json --param diff_file=pr.diff \
--param mod_seat_profile=grok-mod-seat
mod_seat_profile names a profile in ~/.darkmux/profiles.json; leave it unset and the dispatch resolves coder through the machine's own role_profiles.coder binding instead, which is the other way to point a runner at a seat. A name that matches no defined profile refuses the launch and lists what is defined — it is not resolved to your default profile, because a cloud seat quietly demoted to the local default looks identical in the graph and in every flow record. The dry run is where a runner's typo surfaces. Three endpoint profiles, all OpenAI-compatible. Each endpoint is declared once under the registry's top-level endpoints map and named by id from the model. url is the base up to /chat/completions, and auth.keychain names a macOS Keychain item, never a key:
{ "endpoints": {
"grok": { "url": "https://api.x.ai/v1",
"auth": { "type": "bearer", "keychain": "darkmux-grok" } },
"gemini": { "url": "https://generativelanguage.googleapis.com/v1beta/openai",
"auth": { "type": "bearer", "keychain": "darkmux-gemini" } },
"openai": { "url": "https://api.openai.com/v1",
"auth": { "type": "bearer", "keychain": "darkmux-openai" },
"limits": { "tokens_per_dispatch": 500000 } } },
"profiles": {
"grok-mod-seat": { "models": [ { "id": "<grok-model-id>", "n_ctx": 128000, "endpoint": "grok" } ] },
"gemini-mod-seat": { "models": [ { "id": "<gemini-model-id>", "n_ctx": 128000, "endpoint": "gemini" } ] },
"openai-mod-seat": { "models": [ { "id": "<openai-model-id>", "n_ctx": 128000, "endpoint": "openai" } ] } } }
An endpoint with a url is one darkmux only sends requests to: it never loads or unloads anything there, and it knows the model it asked for and whatever the reply reports, nothing more. A model with no endpoint is sent to the LM Studio at lmstudio_url, which darkmux manages ("managed": "lmstudio" says the same thing explicitly; a managed endpoint takes no url, its address is lmstudio_url). The request shape defaults to max_completion_tokens, which hosted reasoning models require; a server that only accepts max_tokens declares "dialect": "chat-completions-max-tokens". limits (tokens_per_dispatch, concurrent_calls, and a window with a period such as "1d" and tokens or calls) is read and shown by darkmux doctor but not enforced yet; until it is, remote.max_tokens_per_execution below is the bound that applies. An endpoint written inline on a model ("endpoint": { "url": … }, the pre-4.0 spelling) still works, and darkmux doctor names the move to an id.
On a runner with no login keychain to unlock, declare "key_env": "<VAR NAME>" instead — only the variable's name lives in the profile, and a present env var wins over the Keychain item.
Data boundary. This seat sends the finding and the source lines it names to a third party. Use it on public repositories only. Work under a client or employer boundary stays on a local seat or on that organization's own endpoint — the attended hook path exists for exactly that case.
Every dispatch writes a dispatch complete flow record carrying prompt_tokens, completion_tokens and, for a remote seat, endpoint. Sum the coder handle for one mission and put it beside the diff you fed in:
jq -r 'select(.action == "dispatch complete" and .handle == "coder"
and .mission_id == "<mission-id>")
| [.payload.prompt_tokens, .payload.completion_tokens] | @tsv' \
~/.darkmux/flows/*.jsonl \
| awk '{p+=$1; c+=$2} END {printf "prompt %d completion %d total %d\n", p, c, p+c}'
wc -l pr.diff # the size the run was asked to review
Run that on a handful of PRs and you have a tokens-per-diff-line curve for your repository, which is the number to budget against. Tokens only — darkmux never prints a currency figure, because the rate is your contract with the provider and not something a run can know.
The ceiling today is time, not tokens. remote.max_tokens_per_execution meters the review mission's remote seats (its create-mod-dispatch step) and any other endpoint-staffed unit dispatch. It does not yet meter the agentic-remote container loop (#1187), which is the path a tool-granting role like coder takes. Until that lands, the only bounds on this seat are the inactivity budget (DARKMUX_INACTIVITY_TIMEOUT_SECONDS, default 600) and the turn cap (runtime.max_turns, unset means uncapped). Set a turn cap before pointing a runner at a metered endpoint unattended.
crawl.json is untouched by all of this: a crawl still dispatches its own coder for mods, and ships that task off by default. Crawl walks any corpus; review is diff-scoped and code-only, and neither config knows the other exists.
Names and defaults from docs/ENVIRONMENT.md:
| Env var | Default | config.json field | What it does |
|---|---|---|---|
DARKMUX_TURN_DELAY_MS |
0 |
runtime.turn_delay_ms |
Rest between turns, recorded as rest_ms/rests beside an unchanged wall_ms. Clamped to half of DARKMUX_INACTIVITY_TIMEOUT_SECONDS, with a warning when the clamp fires. |
DARKMUX_HOST_SAMPLER_INTERVAL_MS |
5000 |
runtime.host_sampler_interval_ms |
The serve daemon's host sampler cadence (CPU/memory/thermal/GPU from kernel counters, no model work). 0 disables it. |
DARKMUX_HOOKS_ENABLED |
false |
hooks.enabled |
The whole-feature gate. Rules have no env form. |
DARKMUX_HOOKS_MAX_OUTBOX_MB |
256 |
hooks.max_outbox_mb |
Cap on one rule's undelivered bytes; further appends are dropped and counted. 0 disables the cap. |
DARKMUX_HOOK_SECRET_<rule-index> |
unset | — (a secret; never config.json) | A rule's HMAC-SHA256 secret, e.g. DARKMUX_HOOK_SECRET_0 for rules[0]. Wins over the Keychain item. Never logged. |