Core0 / Nio
Core0
GitHub v2.4.4
Diagnostics

# Diagnostics — silent failures, made visible.

When something is misconfigured — a stale OAuth key, a missing LLM API key, an unreachable scoring endpoint — Nio surfaces a structured `Diagnostic` through three coordinated channels so the failure can't hide. Decisions are never blocked by a recoverable failure; the affected phase simply drops out of the weighted average, and the reason is recorded.

## Three channels

Channel | Where | When to use
--- | --- | ---
Audit log | `~/.nio/audit.jsonl`, lines with `"event": "diagnostic"` | Postmortem / aggregation. `/nio report` reads this and groups entries by `(source, kind, component, config_path)`.
Hook → agent | Claude Code: `hookSpecificOutput.additionalContext` on allow, appended to `permissionDecisionReason` on ask/deny. Hermes: appended to the block `reason`, or stderr on allow. | Push to the agent in real time. The agent sees a compact "1 error, 2 warnings during this action" block with hint lines, so the next reply can mention the broken config to the human.
Structured stderr | `[nio:<source>:<kind>] <message>` on the hook process's stderr, with a `hint:` continuation line. | Developer debugging. Visible when running CLIs directly; partly visible inside hook contexts depending on the platform.

All three are populated unconditionally — there is no write-side deduplication. The `/nio report` reader collapses repeats. This means an active OAuth misconfig will continue to log on every tool call, but the report will show one row with `Count: 47` instead of 47 noisy rows.

## The `Diagnostic` shape

    {
      "event": "diagnostic",
      "timestamp": "2026-05-25T14:31:08.000Z",
      "severity": "error" | "warning" | "info",
      "source": "config" | "oauth" | "llm" | "external_analyser"
              | "collector" | "scanner" | "hook",
      "kind": "<source-specific identifier>",
      "message": "<human-readable summary>",
      "component": "<endpoint name / hostname / etc.>",
      "config_path": "<dot-path into config that's responsible>",
      "detail": "<HTTP status, stack trace excerpt, etc.>",
      "hint": "<suggested fix>"
    }

## Source × Kind reference

Source | Kind | Severity | Default `config_path` | What it means
--- | --- | --- | --- | ---
`config` | `schema_invalid` | error | file path | Zod validation rejected the YAML. Field-specific error in `detail`.
`config` | `yaml_parse_failed` | error | file path | YAML syntax error (indentation, unbalanced quotes, etc.).
`oauth` | `token_failed` | error | `guard.external_analyser[*].auth` | `/token` rejected the `client_credentials` grant. Almost always wrong `client_id` / `client_secret`.
`oauth` | `cache_write_failed` | warning | — | Couldn't persist the access token to `~/.nio/oauth-cache/`. Usually permissions on a multi-user system.
`llm` | `api_key_missing` | error | `guard.llm_analyser.api_key` | `enabled: true` paired with an empty `api_key`. Phase 5 silently skips otherwise.
`llm` | `api_call_failed` | error | `guard.llm_analyser.api_key` | Anthropic API call errored (4xx/5xx, network, rate limit).
`external_analyser` | `auth_failed` | error | `guard.external_analyser[*].auth` | `auth` was configured but resolved to no header. The fetch is skipped — no 401 noise.
`external_analyser` | `http_error` | error | `guard.external_analyser[*].endpoint` | Scoring endpoint returned non-2xx.
`external_analyser` | `timeout` | error | `guard.external_analyser[*].timeout` | Request exceeded the per-endpoint `timeout`.
`external_analyser` | `network_error` | error | `guard.external_analyser[*].endpoint` | DNS / connection refused / TLS error.
`external_analyser` | `response_invalid` | error | `guard.external_analyser[*].endpoint` | Endpoint returned 200 but the body didn't match the required schema (`{ "score": number, "reason"?: string }`). `detail` includes a body preview so you can compare expected vs actual. See Phase 6 → Response contract.
`collector` | `otlp_export_failed` | warning | `collector.endpoint` | OTLP exporter couldn't deliver telemetry.
`collector` | `collector_core_error` | warning | — | collector-core failed to process a hook event (rare, indicates a bug or malformed input).
`scanner` | `file_read_failed` | warning | — | File walker couldn't read a file. Usually permissions or symlink loops.

## `/nio doctor`

Doctor is the canonical place to validate a config change. Unlike `/nio report` (which reads the audit log), doctor actually executes the network checks live — it acquires a real OAuth token against each enabled `external_analyser` entry and re-runs schema validation on the on-disk YAML. Run it after every config edit, or when something doesn't behave the way you expect.

Collector reachability is intentionally not probed — its config correctness is covered by the schema check, and OTLP gateways commonly require routing headers (e.g. `x-event-pipeline-id`) that a bare probe wouldn't include, producing misleading 401/403 reports. Export failures surface at runtime as `collector / otlp_export_failed`.

    ## Nio Doctor

    ### Configuration
    - ✓ ~/.nio/config.yaml loaded successfully

    ### External Analysers (3 configured)
    - ✓ scorer_a (https://a.example.com/score) — OAuth token acquired
    - ✗ scorer_primary (https://scoring.example.com): client_credentials grant failed at https://scoring.example.com/oauth/token
        hint: Check client_id / client_secret in guard.external_analyser[].auth, or run /nio doctor.
    - · scorer_off (https://x.example.com/score) — disabled

    ### LLM Analyser
    - ✗ guard.llm_analyser.enabled=true but api_key is empty
        hint: Set guard.llm_analyser.api_key to an Anthropic API key, or set enabled: false.

Doctor's checks never write to the audit log — its probes use a scoped collector that's discarded when the command returns. So you can re-run doctor as often as you like without polluting `/nio report`.

## `/nio report` — Diagnostics summary

When `~/.nio/audit.jsonl` contains diagnostic entries, `/nio report` appends a "Diagnostics" section below the decisions table, aggregated by `(source, kind, component, config_path)`:

    ## Diagnostics (last 47 of 200 events)

    | Count | Source | Kind | Component | First seen | Last seen | Latest message |
    |-------|--------|------|-----------|------------|-----------|----------------|
    | 32 | oauth | token_failed | scorer_primary | 14:02:11 | 14:31:08 | client_credentials grant failed at https://scoring.example.com/oauth/token |
    | 12 | llm | api_key_missing | — | 14:02:11 | 14:31:08 | guard.llm_analyser.enabled=true but api_key is empty |
    |  3 | external_analyser | timeout | scorer_slow | 14:10:00 | 14:25:00 | https://slow.example.com/score timed out after 3000ms |

    Hints:
      - oauth token_failed: Check client_id / client_secret in guard.external_analyser[].auth, or run /nio doctor.
      - llm api_key_missing: Set guard.llm_analyser.api_key to an Anthropic API key, or set enabled: false.
      - external_analyser timeout: Increase guard.external_analyser[].timeout or investigate endpoint latency.

## Walkthrough: an OAuth misconfig from start to fix

1. You edit `~/.nio/config.yaml` and add a new external_analyser entry, but you paste a wrong character into `auth.client_secret`.
2. The next time the agent runs a tool call (any Bash, Write, Read…), Nio's Phase 6 picks up the new endpoint, the OAuth strategy POSTs to `/token` with `grant_type=client_credentials`, and gets back HTTP 401.
3. The hook output includes an `additionalContext` block:

       Nio: 1 error during this action
         - [oauth token_failed] scoring.example.com: client_credentials grant failed at https://scoring.example.com/oauth/token
           hint: Check client_id / client_secret in guard.external_analyser[].auth, or run /nio doctor.
       Run /nio doctor or /nio report for details.

   The agent sees this and mentions it in its next response to you.
4. You run `/nio doctor`:

       ### External Analysers (1 configured)
       - ✗ scorer_primary (https://scoring.example.com/api/.../score): client_credentials grant failed at https://scoring.example.com/oauth/token
           hint: Check client_id / client_secret in guard.external_analyser[].auth, or run /nio doctor.

5. You fix `auth.client_secret` in the YAML.
6. Re-run `/nio doctor` — now ✓.

Throughout, the agent's tool calls were never blocked. Phase 6's `scorer_primary` simply dropped out of the weighted aggregate during the broken period; its slot in `phase_timings.external` still appears in the audit log with an `error: { kind: 'token_failed', detail: '...' }` field, so the gap is auditable.