config.json
Persist models, tools, and options without exporting env vars every session.
File locations
| OS | Path |
|---|---|
| Linux | ~/.config/ninja/config.json |
| macOS | ~/Library/Application Support/ninja/config.json |
| Windows | %APPDATA%\ninja\config.json |
On first run Ninja also writes **config.example.json** in the same directory (annotated template with REPLACE_* placeholders). It is not overwritten once present and never replaces your live config.json.
Model profiles (overlay)
Named profiles live in profiles/<name>.json next to config.json (same ConfigDir). At launch, ninja --profile <name> or NINJA_PROFILE=<name> overlays model fields only onto this base file:
- Overlaid:
openai_*,planner,ninja_subagent_*/ legacysubagent_*,subagent_roles - Not overlaid: tools,
cache,retry,doom_loop, **plan_panel** (stays global), and other non-model keys
Bare ninja does not load a profile. Missing profile files fail launch. With a profile active, /model save for planner/roles patches the profile file; panel saves still write base config.json.
Details and examples: Model profiles.
Setting precedence
The rule is specific target config > env > general config > defaults:
- Target-specific config — the
plannerblock,subagent_roles.<role>, andplan_panelentries. These beat env vars. - Non-empty environment variables (
OPENAI_*,NINJA_SUBAGENT_*) - **General
config.jsonfields** (openai_*,ninja_subagent_*) - Built-in defaults (planner base defaults to
http://localhost:8080if unset)
So an exported OPENAI_MODEL silently overrides openai_model — but never a planner block. If you edit openai_model and nothing changes, run ninja doctor: it prints where each value came from and warns when an env var shadows a config field.
Pinning the planner
Use this when OPENAI_MODEL / OPENAI_BASE_URL in your shell keep overriding what you put in openai_* — common when other tools export those vars.
{
"planner": {
"base_url": "https://api.example.com/v1",
"api_key": "REPLACE_PLANNER_KEY",
"model": "planner-model"
}
}
Fields set in planner outrank OPENAI_* env vars, the same way subagent_roles entries outrank NINJA_SUBAGENT_*. /model set planner <pick> followed by save writes here, so a saved pick survives any shell environment. Run ninja doctor if something still looks wrong — it shows the source of each field.
Minimal example
{
"openai_base_url": "http://localhost:8080",
"openai_api_key": "",
"openai_model": "your-model-name"
}
Hybrid routing example
{
"openai_base_url": "https://api.example.com/v1",
"openai_api_key": "REPLACE_PLANNER_KEY",
"openai_model": "planner-model",
"ninja_subagent_base_url": "http://localhost:8080",
"ninja_subagent_api_key": "sk-local",
"ninja_subagent_model": "coder-model",
"subagent_roles": {
"reviewer": {
"base_url": "https://api.example.com/v1",
"api_key": "REPLACE_REVIEWER_KEY",
"model": "review-model"
}
},
"enabled_tools": {
"bash": true,
"web_search": true,
"read_url": true,
"deep_research": true
},
"plan_panel": [
{
"name": "risk-lens",
"base_url": "https://api.example.com/v1",
"api_key": "REPLACE_PANEL_KEY_1",
"model": "strong-reasoner-model",
"lens": "risk-first"
},
{
"name": "simple-lens",
"base_url": "https://api.example.com/v1",
"api_key": "REPLACE_PANEL_KEY_2",
"model": "another-strong-model",
"lens": "simplicity-first"
}
]
}
plan_panel is optional. Use it with the plan-debate skill (see Configure models). More knobs also appear in config.example.json after first run.
Common keys
| Key | Purpose |
|---|---|
openai_base_url / openai_api_key / openai_model |
Planner (orchestrator) |
ninja_subagent_* |
Default for subagents |
subagent_roles |
Per-role overrides (coder, reviewer, final_review, …) |
plan_panel |
Models for multi-model planning (plan-debate skill) |
enabled_tools |
Turn built-in tools on/off |
cache |
Context management toggles (see example file) |
retry |
Classified HTTP retries for completions / stream-connect |
doom_loop |
Mid-stream tail-repetition abort / resample / disarm |
post_edit_check |
Immediate node --check after successful write tools (default true) |
force_fresh_coders |
Tri-state: absent=auto (force fresh when coder window ≤ 64k), true=always, false=escape hatch |
update_check |
Interactive startup update prompt (live CDN check) + headless cache refresh (default true) |
Reliability: retries & doom-loop
Local small models and flaky network paths need more than fail-fast HTTP. Ninja classifies completion errors and watches streamed output for degenerate loops.
retry — classified HTTP retries
Applies to non-streaming completions and the stream connect phase (headers / status before the first SSE line). Mid-body stream drops are handled separately (one fresh re-ask).
{
"retry": { "max_retries": 6 }
}
| Key | Default | Meaning |
|---|---|---|
max_retries |
6 (when 0 or omitted) |
Attempt budget for connect/completion retries |
**Retries (with exponential backoff 500ms→15s + jitter; Retry-After integer seconds honored, capped 15s):**
- HTTP 408, 429, 5xx
- Transient transport errors (not cancel / deadline)
429 is capped at 2 retries (3 attempts total); a third consecutive 429 is fatal so long rate-limit waits don't burn the whole budget.
Never retried (fatal):
- Context overflow — typed
ContextOverflowErrorwhen the body matches known llama.cpp / vLLM / OpenAI-compat window phrases. The review loop may burn a fix round with a compact-prompt hint; the client itself does not re-POST the same oversized request. - Other 4xx (auth, bad request, not found, …)
- JSON / decode failures
context.Canceled/DeadlineExceeded- Unknown error shapes (fail closed)
doom_loop — tail-repetition protection
Small local coders sometimes emit the same tail block forever. While a stream is live, Ninja feeds content and reasoning deltas into a rolling detector. On fire it aborts the attempt (partials do not stick in history), resamples the same request, and after the resample budget is exhausted disarms so a final attempt is accepted as-is.
{
"doom_loop": {
"enabled": true,
"window_chars": 0,
"min_block_chars": 0,
"min_repeats": 0
}
}
| Key | Default | Meaning |
|---|---|---|
enabled |
true when omitted |
Set false to disable detection entirely |
window_chars |
4096 when 0 |
Rolling character window |
min_block_chars |
48 when 0 |
Minimum repeated block size |
min_repeats |
4 when 0 |
Consecutive tail copies required to fire |
Behavior: fire → abort → up to 2 resamples (≤500ms jitter between attempts) → disarm and accept the third attempt. Provider finish_reason: repetition_truncation counts as a fire while budget remains. A mid-stream transport drop after the first token gets one separate re-ask (independent of the connect retry budget).
post_edit_check — diagnostics after edit
After a successful write_file or target_edit, Ninja can run node --check on the written JS/HTML file and splice findings into that tool result under ⚠ post-edit check:. Clean files, missing node, non-JS extensions, and hung checks (5s cap) stay silent. Failed tool calls are never checked; a failed check never fails the write.
{
"post_edit_check": true
}
| Value | Meaning |
|---|---|
omitted / true |
Enabled (default) |
false |
Disabled — write tool results unchanged |
Multiple writes to the same path in one batch check only the last. End-of-round syntax evidence inside execute_task is separate and always available when node is present.
force_fresh_coders — pin every execute_task to a fresh coder
Session mode pools one persistent coder across a plan. On small context windows that accumulate→compact cycle can thrash; this tri-state knob forces every execute_task onto a fresh per-task coder instead (regardless of the planner's mode: "session"). build_project is unaffected.
| Value | Meaning |
|---|---|
| omitted (auto) | Force fresh when the coder window is known and ≤ 65536 tokens; larger / unknown windows keep session available |
true |
Always force a fresh coder |
false |
Never force — allow session even on a 64k window (escape hatch) |
When a session request is overridden, the result carries a cause-carrying warnings entry (config vs window auto) rather than failing the call, and the planner prompt includes a matching CODER_MODE_NOTE so goals stay self-contained. Fresh coders still receive the automatic parent-context brief.
{
"force_fresh_coders": false
}
update_check — release check + update prompt
When enabled (default), interactive TUI starts do a live GET of latest.json (≤2s). If a newer release exists, Ninja asks:
⬆ ninja vX.Y.Z available (current vA.B.C). Update now? [y/N]
- y / yes — runs the same checksum-verified self-update as
ninja update, then exits so you restart - n / Enter — continues this session; run
ninja updatelater anytime
Headless (-p / --mode json) and non-TTY stdin never prompt; they only refresh the local cache in the background (at most once per 24h). Dev builds skip this path. Network failures are silent (session continues).
{
"update_check": true
}
| Value | Meaning |
|---|---|
omitted / true |
Enabled (default) |
false |
No check, no prompt, no background refresh |
Also disable with env NINJA_NO_UPDATE_CHECK=1. Privacy: plain GET of latest.json — no query parameters or identifying payload. ninja update still works when the check is off.
Project-local files
| Path | Purpose |
|---|---|
.ninja/mcp_config.json |
Project MCP servers |
.ninja/skills/ |
Project skills |
.ninja/allowed_commands.json |
Project-scoped command trust |