10 · advanced

Crawl a codebase against your rules, and route what it finds

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.

1. The workspace spec

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"]
}

Preview the graph with --dry-run

Nothing 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.

2. Rules: a template kind, like roles

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:

RuleKindWhat it looks for
unnamed-predicatesiteA compound boolean condition inlined into a branch instead of named, so its reason is nowhere written down.
swallowed-errorsiteA catch/.catch(...)/void somePromise() site where the failure is neither rethrown, logged, nor checked by the caller.
doc-contradicts-codereadA doc comment or README claim the adjacent code visibly does not do.
stale-consumeredgeA 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.

Rule problems surface in 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.

3. Running a unit

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

The record sequence

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.

4. Findings and mods

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 finding on the flow stream

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)"]
}

Mods

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.

5. Hooks: routing flow records to your own receiver

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).

The config block

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" }
    ]
  }
}

The match vocabulary

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.failed

Every 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).

Delivery headers

HeaderValue
X-Darkmux-DeliveryA 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-EventThe record's action.
X-Darkmux-Machine-Id / X-Darkmux-Machine-UidThe machine that produced the record.
X-Darkmux-SenderThe machine POSTing, which differs from the producer when a hub relays another machine's record.
X-Darkmux-TimestampUnix milliseconds at send time.
X-Darkmux-SignatureOnly 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 status

Captured 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 drain

Flushes 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.

A minimal receiver

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.

Transforms: reshaping a record for someone else's API

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).

Three testing tiers

  1. Adapter alone. Fixtures come from the flow log: jq -c 'select(.payload.tool_name=="create_finding")' ~/.darkmux/flows/<date>.jsonl | head -1 | jq -f ~/.darkmux/hooks/adapters/jira-issue.jq.
  2. 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" }
  3. The full path against the receiver above, then the real endpoint.

darkmux doctor

A 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)

Limits

6. Findings to suggestions: the hook is the frontier seat

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

The rule to add

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.

The wait, and why it defaults to zero

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.

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.

Unattended: a cloud seat

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.

What the seat costs, in tokens

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.

7. The knobs

Names and defaults from docs/ENVIRONMENT.md:

Env varDefaultconfig.json fieldWhat 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.