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
- You edit
~/.nio/config.yamland add a new external_analyser entry, but you paste a wrong character intoauth.client_secret. - 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
/tokenwithgrant_type=client_credentials, and gets back HTTP 401. - The hook output includes an
additionalContextblock:
The agent sees this and mentions it in its next response to you.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. - 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. - You fix
auth.client_secretin the YAML. - 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.