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_* / legacy subagent_*, 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:

  1. Target-specific config — the planner block, subagent_roles.<role>, and plan_panel entries. These beat env vars.
  2. Non-empty environment variables (OPENAI_*, NINJA_SUBAGENT_*)
  3. **General config.json fields** (openai_*, ninja_subagent_*)
  4. Built-in defaults (planner base defaults to http://localhost:8080 if 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 ContextOverflowError when 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 update later 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