Everything config.yaml can express: adding a harness, endpoint modes, what
configure writes, and a reference for every key. The file is
optional — harness-dispatch auto-detects installed CLIs and runs without one.
config.yaml is entirely optional. There is no separate hidden defaults format —
harness-dispatch ships with its own config.default.yaml, the
same shape you'd write yourself, and reads it as its built-in config. With no
config.yaml of your own, the shipped one is filtered down to whichever of claude,
codex, agy (Antigravity; found but left off until you opt in, see
vendor terms), and cursor-agent are on your PATH:
# config.yaml can be empty, or not exist at all.Adding a harness that isn't auto-detected — a second Codex route pinned to a specific model, or a local/hosted OpenAI-compatible endpoint — is a few lines:
detect: true # keep auto-detected harnesses as well; see below
clis:
- name: codex_sol
harness: codex # picks the dispatcher: claude_code | codex | cursor | antigravity_cli | generic
model: gpt-5.6-sol
tier: 1
endpoints:
- name: ollama
base_url: http://localhost:11434/v1
model: qwen2.5-coder
tier: 3This is the one rule every other page links to. A config that lists any clis: or
endpoints: is authoritative: it gets exactly the routes it names, and nothing
is auto-detected alongside them. So detect: true is doing real work in the snippet
above. Without that line it does not add codex_sol and ollama to your installed
harnesses — it replaces them, and claude_code_cli, codex_cli, cursor_cli and
antigravity_cli are gone. The same rule means disabled: and overrides: (which
tune auto-detected routes) do nothing in a file that lists routes and omits
detect: true; doctor and status warn when that happens.
Which one you want depends on the goal:
| You want | Write |
|---|---|
| My routes plus whatever is installed | detect: true alongside your entries |
| Exactly the routes I list, nothing else | just the entries (the default) |
| Nothing but auto-detection, minus a route | no clis:/endpoints:, plus disabled: [name] |
| No routes at all | detect: false |
Every key is listed in the config reference below. The shipped
config.default.yaml is worked examples of the four built-in
harnesses, not a template: do not copy it (most of it is rationale). Start from
the short file harness-dispatch configure --print shows and add entries to it.
A wholly new CLI harness (one of the 4 built in isn't it) needs no new code
either. harness: generic takes a protocol: block instead of reusing one of the
4 built-in harnesses' flag/output conventions. protocol.args is a literal
command-line argument list, written the same way you'd type it by hand. A handful of
reserved {{name}} tokens are substituted (or expanded to zero or more real tokens) at
dispatch time; everything else passes through verbatim:
clis:
- name: my_custom_cli
harness: generic
command: my-cli # the binary, resolved on PATH like any other
tier: 3
protocol:
args: ["-p", "{{prompt}}", "{{working_dir}}", "{{model}}", "{{safety}}", "--json"]
working_dir: { flag: "--cd" } # omit {{working_dir}}/this to rely on process cwd alone
model: { flag: "--model" } # omit {{model}}/this if the CLI has no model override
safety: # args per requested safety profile, via {{safety}}
read_only: ["--mode", "plan"]
workspace_edit: ["--mode", "accept-edits"]
full_auto: ["--dangerously-skip-permissions"]
output:
mode: json_field # text | json_field | jsonl_stream
fields: [result, output, text] # checked in order; dotted paths work ("message.content")The full token reference:
| Token | Expands to |
|---|---|
{{prompt}} |
the prompt text (one token) — omitted entirely if stdin: true |
{{model}} |
[model.flag, value] if a model is set, else nothing |
{{safety}} |
safety[profile] for the effective profile — the requested one, or the floor effective_safety sets — zero or more tokens |
{{working_dir}} |
[working_dir.flag, dir, ...working_dir.extra_args_when_set] if set, else nothing |
{{file_dirs}} |
[file_dirs.flag, dir] repeated once per included file's directory |
{{native_args}} |
endpoint_native_args[endpoint_provider], only under endpoint_mode: harness_native_endpoint |
Protocols are named and selectable, not just inline. claude_code, codex,
cursor, and antigravity_cli are registered presets — every entry's harness: value
in the shipped config.default.yaml's clis: list is
automatically selectable as a preset name. Reference one by name instead of retyping it:
clis:
- name: my_cursor_fork
harness: generic
command: my-cursor-fork-cli # a different binary that happens to share Cursor's CLI shape
protocol: cursorOr start from a preset and override just what differs, for the common "95% the
same, one flag different" case — safety merges per-profile (overriding just
full_auto doesn't erase read_only/workspace_edit from the preset):
clis:
- name: my_codex_fork
harness: generic
command: my-codex-fork-cli
protocol:
extends: codex
model: { flag: "--llm-model" } # only this differs from the codex preset
safety:
full_auto: ["--yolo"] # only this profile's args are replacedA built-in route's own protocol: (under overrides.claude_code_cli, etc.) accepts a
preset name or extends: too — it's parsed through the exact same code path as any
other route.
The harness: claude_code | codex | cursor | antigravity_cli routes aren't special
either: there is no per-harness dispatcher class or hardcoded TypeScript data for any
of them in this codebase. All 4 are ordinary clis: entries in the shipped
config.default.yaml, not a separate "defaults registry" in
some other format, loaded through the exact same parser as your own config.yaml,
covering each CLI's real flags including Codex's mid-run tool_use/thinking/usage
streaming events via event_rules (see below). Every CLI-type route, built-in or
user-added, runs through the one GenericCliDispatcher interpreter. Copy an entry
from the shipped file into your own config.yaml and edit it directly (or add a
protocol: block under overrides.claude_code_cli, etc.) and it replaces the default
entirely — nothing about the 4 built-ins is more hardcoded than a route you add
yourself.
For a CLI whose events don't fit text/json_field's single-parse-at-exit model —
mid-run tool_use/thinking surfacing, token-usage aggregation across lines — use
output.mode: jsonl_stream with output.event_rules:
output:
mode: jsonl_stream
event_rules:
- when: { type: "message" } # every listed field must match this line
emit: text
text_field: message.content # dotted paths work
- when: { "item.type": "tool_use" }
emit: tool_use
name_field: item.name
input_field: item.input
- when: { type: "thinking" }
emit: thinking
chunk_field: item.text
- when: {} # omit `when` (or leave it empty) to match every line
emit: usage
input_token_fields: [usage.input_tokens, usage.prompt_tokens] # first present wins
output_token_fields: [usage.output_tokens, usage.completion_tokens]Other fields worth knowing: file_dirs: { flag: ... } (paired with {{file_dirs}})
repeats a flag once per unique file directory (Antigravity's --add-dir);
api_key_env_var injects api_key under a named env var for the child process (and
clears it if ambient but unconfigured, so a stray key never leaks into a
subscription-auth call); success_requires_output: false switches from the default
strict contract (exit 0 AND a non-empty parsed field) to the lenient one Claude
Code/Codex use (exit 0 alone, falling back to raw stdout/stderr text when
parsing yields nothing). Billing for a generic route defaults to unknown (blocked
until you classify it — there's no way to know an arbitrary CLI's real billing model) —
set billing_kind: / paid_usage_possible: explicitly once you know it.
Codex's full_auto runs with --sandbox workspace-write, the same as
workspace_edit. That is deliberate: the unsandboxed reading of "full auto" is
danger-full-access, and the shipped default keeps shell inside Codex's own
sandbox. If you really want the unsandboxed behaviour for that route, say so in
your own config:
overrides:
codex_cli:
protocol:
extends: codex
safety:
full_auto: ["--sandbox", "danger-full-access"] # only this profile changessafety merges per profile, so read_only and workspace_edit keep their
shipped sandboxes.
configure is the main setup flow. It does four things, in order, and prompts for
nothing except the final registration:
- Detect installed harness CLIs on PATH (or load the existing config, when there is one it did not write itself — see the note on re-runs under Install).
- Print every route with its billing classification and effective safety profile, and say which routes are blocked until you opt in to paid usage.
- Write the config YAML (
--yes; without it, nothing is written). - Offer to register with each MCP client it finds, or print the snippet
(
--no-clients).
There is no interactive choice of harnesses, model priority or safety profile: edit the written file for those.
The current command is conservative: it prints detected routes by default and writes
only when explicitly asked with --yes.
Writing the config is also what turns detection off: the detected harnesses become
clis: entries, and a config that lists routes is authoritative, so the PATH lookup
no longer runs at every start. With no config file, detection runs every time (it is
fast, but not free); configure --yes once removes that.
Routing policy for this machine — which hints.modelTier for which kind of task,
which route to prefer — can live in config.yaml instead of in every client's own
instruction file (CLAUDE.md, AGENTS.md and the like):
instructions: | # told to every agent that connects
Prefer Codex for refactors; modelTier strong only for hard judgment.
overrides: # an auto-detected route
claude_code_cli:
instructions: "best for plan and review; slow to start, so expect a jobId"
endpoints:
- name: local_box
base_url: http://127.0.0.1:1234/v1
instructions: "one model loaded; leave hints.model unset"- The server sends its own instructions to every agent when it connects, and
appends these: the top-level block, then each enabled route's, listed by route
id. A route's text is also returned for that route by the
usagetool. instructions:works onclis:,endpoints:,overrides:and legacyservices:entries.- Each value is capped at 1,000 characters; longer text is cut, with a warning. Every connected session carries this text in its context.
- It is read when a session connects: an edit reaches sessions that connect afterwards, not ones already open.
- A secret the config holds (an API key, say) is scrubbed from this text.
A route's key can be written three ways. Prefer the first:
endpoints:
- name: groq
base_url: https://api.groq.com/openai/v1
model: openai/gpt-oss-120b
api_key_file: ~/.harness-dispatch/keys/groq # read once at load
# api_key: ${GROQ_API_KEY} # or from the environmentapi_key_file:reads the key from a file when the config loads. A relative path is relative to the config file, and~/is your home directory. The key never enters any process environment, so no other delegate can inherit it; the route's own harness still receives it, as withapi_key:. Keep the file readable only by you (chmod 600on macOS and Linux). A missing or empty file is an error naming the route.configurewritesapi_key_file:back, never the key.api_key: ${VAR}reads an environment variable. That variable is inherited by every process you start, and an MCP client'senvblock that sets it is plaintext JSON in your home directory, readable by any delegate that can read files there. harness-dispatch blanks it in the agents it starts, but it cannot hide the client config file.- A literal
api_key:works and is not recommended.
A caller asks for one of three profiles, and each is a limit rather than a capability:
| Profile | Means |
|---|---|
read_only |
Look, don't touch |
workspace_edit |
Edit files in the workspace, no arbitrary shell |
full_auto |
Edit files and run shell |
A route declares the floor it actually runs at (effective_safety), and is
skipped when that floor exceeds what was asked for. A route is never quietly
given more access than the caller requested.
cursor_cli is the interesting case, because its capability differs by mode:
read_onlyuses--mode plan, which is genuinely read-only (verified: asked to create one file and overwrite another, it did neither).full_autouses print mode, which edits and runs shell.workspace_editis skipped, on every platform. Cursor's print mode grants write and shell together.--sandbox enabled, the flag that would constrain shell while allowing edits, exists only on macOS and Linux, so the shipped route does not use it anywhere. There is no edit-without-shell mode to route to, and claiming that level would mean handing shell access to a caller who explicitly asked not to have it.
Cursor still edits code. Ask for full_auto.
If you accept that Cursor's editing mode carries shell access and you want it
to serve workspace_edit anyway, declare the floor yourself in config.yaml.
Your value replaces the shipped default:
overrides:
cursor_cli:
effective_safety:
read_only: read_only
workspace_edit: workspace_edit # you are accepting shell access here
full_auto: full_autoUse overrides:, not a clis: entry: a config that lists any clis: entry is
authoritative and drops every harness it does not name, so a lone cursor_cli entry
would remove Claude Code, Codex and Antigravity from your routes. See
listing a route turns detection off.
That is a deliberate local decision, not a bug workaround: the shipped default
is conservative because the tool cannot verify what a given cursor-agent
build will do. On macOS and Linux the better route is --sandbox enabled,
which constrains shell for real. It is untested here, so it is not shipped on by
default.
antigravity_cli declares the same floor, for the same reason: in headless
mode every profile has to auto-approve tool requests, and its edit mode does
that with nothing restricting the terminal. It serves read_only (--mode plan --sandbox) and full_auto, and the same override applies.
Each profile is enforced by the harness itself, and the strength differs: Codex
runs inside an OS sandbox, Claude Code and Cursor apply their own in-process
permission rules, and Antigravity's full_auto approves everything. A Claude
Code delegate at read_only or workspace_edit gets only the file tools
(--tools) and no MCP servers (--strict-mcp-config); it still runs with your
Claude Code login, user settings, user-level hooks and CLAUDE.md files, but not
the project's own settings or hooks (--setting-sources user).
Two per-route limits, both optional, both in milliseconds:
clis:
- name: codex_cli
harness: codex
timeout_ms: 2700000 # wall clock per attempt
idle_timeout_ms: 1200000 # stop after this long with no output at alltimeout_mscaps one attempt. Unset, a background job gets what is left of its 60-minute budget (10 minutes for the CLIdispatchcommand anddoctor --live).hints.timeoutMson a dispatch overrides it.idle_timeout_ms(CLI routes only) stops a run that has printed nothing, on either stream, for that long, and reports it as hung. The wall clock alone cannot tell a hung run from a working one, and a hung run holds a concurrency slot until it fires. Set it only on a route whose harness prints as it works: one that prints only its final answer would be stopped partway through every long task.- Shipped defaults:
codex_cliandantigravity_cliget a 15-minute idle limit (both stream an event per step);antigravity_clialso gets a 25-minute wall clock.claude_code_cliandcursor_cliprint their answer only at the end, so they ship neither. A value in your own entry replaces the shipped one.
Each subscription route drives a vendor's own CLI as the logged-in user. The vendors' terms differ on whether that is allowed; the sources, dates and short quotes are in PRODUCT.md, Risks. What that means for the shipped routes:
-
antigravity_cliis opt-in. Google's Antigravity terms object to third-party tools using the service, so auto-detection findsagybut adds the route switched off.statusshows it asskipped (disabled)with the reason, anddoctorrepeats it. To turn it on, say so in yourconfig.yaml:overrides: antigravity_cli: enabled: true
A
clis:entry withharness: antigravity_cliis already that decision and is never switched off.configurewrites a detected opt-in route as an entry withenabled: false; change that totrueto opt in. -
cursor_cliuses your interactive Cursor login, not an API key. The shipped entry declaresauth_source: product_loginand sends no key.CURSOR_API_KEYis passed to the child only when the route has anapi_key:of its own, which also reclassifies the route as metered. If the variable is set in the environment of the server and the route has noapi_key:, it is blanked for the child, so an ambient key cannot move a login route onto metered billing. To use the key path Cursor's headless documentation shows, setapi_key: ${CURSOR_API_KEY}on the route. -
claude_code_cliandcodex_clirun the unmodifiedclaudeandcodexbinaries under your own login; harness-dispatch never reads their login tokens.
harness-dispatch supports two local/custom endpoint patterns:
direct_openai_compatible: harness-dispatch calls an OpenAI-compatible/v1/chat/completionsendpoint directly. This is the right mode for Ollama, LM Studio, vLLM, LiteLLM, and private local HTTP model servers.harness_native_endpoint: a downstream CLI keeps its agent scaffold but is pointed at a supported local provider. Codex currently supports this forollamaandlmstudiothrough--oss --local-provider.
Example direct local route:
endpoints:
- name: ollama
base_url: http://localhost:11434/v1
model: qwen2.5-coder
endpoint_mode: direct_openai_compatible
endpoint_provider: ollama
wire_protocol: openai_chat_completionsExample Codex harness-native local route, as a clis: entry (the same shape
configure writes; everything not set here comes from the shipped codex defaults):
clis:
- name: codex_ollama
harness: codex
model: qwen3-coder:latest
endpoint_mode: harness_native_endpoint
endpoint_provider: ollama
wire_protocol: openai_chat_completions
billing_kind: local_compute
paid_usage_possible: false
tier: 3
weight: 0.75
cli_capability: 1.0
timeout_ms: 900000 # optional; see "Time limits" above
capabilities:
execute: 0.8
plan: 0.7
review: 0.7The older top-level services: format still loads, but it is a separate format:
a file that uses it has its clis:/endpoints: ignored with a warning, so do not
mix the two.
Every key the parser recognises. Anything else is reported as a warning by doctor
and status and has no effect. scripts/check-claims.mjs fails if a recognised key
is missing from these tables.
| Key | Type | Default | Meaning |
|---|---|---|---|
detect |
boolean | true when the file lists no routes, false when it does |
Add harness CLIs found on PATH. See listing a route turns detection off. |
clis |
list of route entries | none | CLI harness routes: a built-in harness, or harness: generic with a protocol: block. Listing any makes the file authoritative. |
endpoints |
list of route entries | none | OpenAI-compatible HTTP routes. name, base_url and model are required, or the entry is ignored with a warning. Listing any makes the file authoritative. |
disabled |
list of route ids | none | Auto-detected routes to leave out. Applies only while detection runs, and never to your own clis:/endpoints: entries (use enabled: false on the entry). |
overrides |
map of route id to route keys | none | Change fields of an auto-detected route, for example overrides: { codex_cli: { tier: 2 } }. Applies only while detection runs. |
api_keys |
map of route id to key | none | A credential by route id. Write ${ENV_VAR}, not the secret. For the four auto-detected ids, <route_id>_api_key: ... at the top level is shorthand. |
max_concurrent_runs |
integer >= 0 | 4 |
Agent CLIs running at once, machine-wide; more queue. 0 lifts the cap. A value that is not a non-negative number is ignored with a warning. |
retention |
{ jobs_days: N } |
7 |
Days a job's files are kept after it last changed. 0 keeps them forever. |
telemetry |
{ enabled: true or false } |
false |
OpenTelemetry tracing. See Observability and privacy. |
leaderboard |
anything | none | Removed. Accepted so an old config keeps loading, reported as removed by doctor and status, and has no effect. Routing is tier, then weight x capability. |
instructions |
text, at most 1,000 characters | none | Policy told to every connecting agent. See Instructions for connecting agents. |
services |
map of route id to route keys | none | The older route format. A file that uses it has clis:, endpoints: and overrides: ignored with a warning; do not mix them. |
version |
anything | none | Accepted and ignored. |
protocol |
anything | none | Accepted and ignored at the top level; a protocol: block belongs inside a route entry. |
protocols, policy, default_safety_profile, workspace_policy |
anything | none | Recognised but not implemented: setting one does nothing, and doctor says so. safety_profile and workspace_policy are real per-route keys, below. |
These work on clis: and endpoints: entries, on overrides: entries, and on legacy
services: entries. "Harness default" means the value the shipped
config.default.yaml gives that harness.
| Key | Type | Default | Meaning |
|---|---|---|---|
name |
text | none (required) | The route id, as usage and service spell it. A name declared twice keeps only the last entry, with a warning. |
harness |
claude_code, codex, cursor, antigravity_cli, generic |
none for a new entry | Which built-in protocol the route uses; generic needs a protocol: block. |
type |
cli or openai_compatible |
cli |
Legacy services: entries only. clis: entries are always CLI routes and endpoints: entries always HTTP. |
command |
text | the harness's binary (claude, codex, cursor-agent, agy) |
The executable, resolved on PATH. |
enabled |
boolean | true; auto-detected antigravity_cli ships false |
false keeps the entry but takes the route out of routing. Turn the opt-in Antigravity route on with overrides: { antigravity_cli: { enabled: true } }; see vendor terms. |
model |
text | none (required for an endpoint) | The model passed to the harness or endpoint. Unset on a CLI route, the harness runs its own default. |
models |
list of text | harness default | Operator-curated model ids. usage lists them, and listModels returns them instead of querying the endpoint. |
model_hint |
text | harness default | Where this harness's real model catalog is documented; shown by usage. |
model_tiers |
map of cheap, standard, strong to a model id |
harness default (claude_code_cli: haiku, sonnet, opus; codex_cli: gpt-5.6-terra for standard, gpt-5.6-sol for strong), but only on an entry with no model: of its own |
The model this route runs for a dispatch's hints.modelTier. A tier with no entry runs the route's default model, reported as routing.modelTierMatched: false. A declared map replaces the harness default rather than merging with it. hints.model wins over it. Shown by usage. |
instructions |
text, at most 1,000 characters | none | Policy for agents using this route; shown by usage and in the server instructions. |
tier |
integer | harness default; 3 for an endpoint |
Lower tiers are tried first; routes in a tier are ranked by score. |
weight |
number | 1.0; 0.6 for an endpoint |
Multiplier in the route's score. |
cli_capability |
number | harness default; 1.0 for an endpoint |
Multiplier for how capable the harness's own agent loop is. |
capabilities |
{ execute, plan, review }, numbers |
harness default; 1.0 each for an endpoint |
Per-task fit, multiplied into the score. |
escalate_model |
text | none | Model to use instead of model when the dispatch's hints.taskType is in escalate_on. |
escalate_on |
list of execute, plan, review, local |
[plan, review] |
Task types that get escalate_model. Does nothing without it. |
timeout_ms |
integer, milliseconds | none (60 minutes on MCP and HTTP dispatches); antigravity_cli ships 25 minutes |
Hard ceiling for a run on this route. hints.timeoutMs wins over it. See Time limits. |
idle_timeout_ms |
integer, milliseconds | none; codex_cli and antigravity_cli ship 15 minutes |
CLI routes only: stop a run that has printed nothing on either stream for this long, and report it as hung. Accepted and ignored on an endpoints: entry. See Time limits. |
resource_weight |
number >= 0 | 1.0 for a CLI route, 0.1 for an endpoint |
What one run of this route counts for against max_concurrent_runs. |
max_input_tokens, max_output_tokens |
integer | harness default | Context and output limits. The input limit feeds preferLargeContext; the output limit is the max_tokens an endpoint is sent. |
thinking_level |
low, medium, high |
harness default | Sent to an endpoint route as reasoning_effort. A CLI route does not read it. |
leaderboard_model |
text | none | Removed. Accepted and reported as removed; it has no effect. Delete the line. |
api_key |
text | none | A credential for this route; write ${ENV_VAR}. A key on a CLI route marks it metered. |
api_key_file |
path | none | Read the key from this file when the config loads, instead of writing it or an env var here. Relative to the config file; ~/ is your home directory. Setting it together with api_key, or pointing it at an unreadable or empty file, is an error naming the route. See API keys. |
base_url |
URL | none (required for an endpoint) | The endpoint's API root. |
protocol |
preset name, or a block | harness default | How to call a CLI. See Adding a harness. |
filter |
anything | none | Accepted and ignored. |
provider, surface, auth_source |
text, from the lists in status |
harness default; inferred from base_url for an endpoint |
Billing identity, as status reports it. |
billing_kind |
local_compute, included_plan_usage, included_plan_then_flexible_credits, included_credit_then_optional_overage, included_usage_then_on_demand, metered_api, free_quota, unknown |
harness default; metered_api with an api_key |
What this route bills against. An unknown value is warned about, not guessed. |
billing_confidence |
documented, inferred, unknown, unsupported |
harness default | How sure the classification is. |
billing_notes |
text | none | Shown as the route's note: in status. |
paid_usage_possible |
boolean | harness default; true with an api_key |
Whether a call can cost money. |
allow_paid_usage |
boolean | false |
Opt in to a route that can cost money or whose billing is unknown. Without it such a route is skipped. |
safety_profile |
read_only, workspace_edit, full_auto |
workspace_edit |
The profile requested for this route when the caller names none. |
effective_safety |
one profile, or a map of requested profile to floor | harness default | What the route really runs at. A route is skipped when its floor exceeds what was requested. |
workspace_policy |
shared, shared_locked, copy, git_worktree |
shared for read_only, else shared_locked |
Where the run happens. A caller's workspacePolicy wins over it. See Operating it. |
endpoint_mode |
direct_openai_compatible, harness_native_endpoint |
direct_openai_compatible for an endpoint |
See Endpoint modes. |
endpoint_provider |
text, such as ollama or lmstudio |
inferred from base_url |
Which local provider a harness-native endpoint points at. |
wire_protocol |
openai_chat_completions |
openai_chat_completions with an endpoint mode |
The wire format. |