External Scoring — your enterprise policy gets the final word.
Optional. When enabled, Nio GETs your scoring endpoint and uses the returned score in the weighted average. Use this for compliance routing (HIPAA, SOC2), DLP integration, or any policy specific to your org that doesn't belong in shared rules.
| Property | Value |
|---|---|
| Latency | configurable (per-endpoint HTTP timeout) |
| Scope | All guarded actions (when one or more endpoints enabled) |
| Endpoints | 0..N, each with its own weight in the aggregate |
| Concurrency | All enabled endpoints called in parallel (Promise.allSettled) |
| Default state | disabled — empty array (external_analyser: []) |
| Short-circuit | any endpoint scoring ≥ active deny threshold → deny immediately, siblings ignored (details) |
| On auth/HTTP failure | endpoint drops out of the average; the failure is surfaced via diagnostics |
Response contract
Your scoring endpoint must return a JSON body of this exact shape:
{
"score": 0.42, // required: number, clamped to [0, 1]
"reason": "..." // optional: string explanation
}
Anything else — wrong field name, nested score, score as a string, JSON array, non-JSON body — is rejected with an external_analyser / response_invalid diagnostic that includes a 200-byte preview of what the endpoint actually returned, so you can see expected-vs-actual at a glance.
nio does not support custom field names, nested paths (data.score), or template responses. If your existing scoring service returns a different shape, deploy a thin shim service on your side that maps it to this contract. The shim is typically 10 lines of code and keeps nio's surface area small.
You can verify your endpoint conforms before triggering any hook by running /nio doctor — it issues a real request and reports the parsed score, or the validation failure with the body preview.
Enable
guard:
external_analyser:
# No auth
- name: scorer_plain
enabled: true
endpoint: "https://plain.example.com/score"
timeout: 3000
weight: 1.0
# Bearer / static API key
- name: scorer_a
endpoint: "https://a.example.com/score"
weight: 2.0
auth:
type: bearer
api_key: "sk-..."
# OAuth2 client_credentials. Pre-register the client with the OAuth
# provider and paste its credentials below.
- name: scorer_primary
endpoint: "https://scoring.example.com/api/scores/agent?agent-name=cc"
weight: 2.0
# headers: # optional custom headers
# X-Tenant-Id: "..."
auth:
type: oauth
oauth_url: "https://scoring.example.com/oauth"
client_id: "..."
client_secret: "..."
See guard.external_analyser for the full schema.
For local development, the repo bundles a mock scorer at http://localhost:9090.
Wire format
Nio issues a GET against each endpoint and expects a JSON response with at least a score field in [0, 1]. The endpoint URL is the entire request — encode any context the endpoint needs as query parameters (e.g. ?agent-name=X). Authentication (when configured) flows through the Authorization: Bearer <token> header. Response example:
GET /api/.../scores/agent?agent-name=cc
Authorization: Bearer eyJ…
← { "score": 0.94, "reason": "production bucket" }
Weights
Each endpoint declares its own weight (default 1.0). The final score is the weighted average across all phases that ran:
final_score = Σ(wᵢ × sᵢ) / Σ(wᵢ)
An endpoint that fails (network / HTTP / auth) drops out of both numerator and denominator — its weight does not pollute the average.
Per-endpoint short-circuit
Weights only matter when the pipeline reaches the weighted-average step. Phase 6 evaluates short-circuit per endpoint: if any single enabled endpoint returns a score that crosses the active deny threshold (strict 0.5, balanced 0.8, permissive 0.9), the pipeline short-circuits on that endpoint alone and the verdict is deny. Sibling endpoints — even with high weights — cannot pull the verdict back down.
Concretely, suppose three endpoints answer at balanced level:
scorer_ffwd_agent_1hr = 0.0953
scorer_ffwd_agent_10min = 0.0755
scorer_ffwd_agent_env = 0.8898 ← crosses balanced deny (≥ 0.8)
weighted avg (would have been): (0.0953 + 0.0755 + 0.8898) / 3 ≈ 0.35
final : 0.89 (single-endpoint short-circuit)
verdict: DENY
The 0.89 endpoint short-circuits the pipeline; the two low-scoring siblings do not enter the verdict. This is intentional — a single authoritative DLP / compliance endpoint should be able to deny without being averaged away by quieter scorers. See Scoring · Short-circuit for the full rule set.
If you want the opposite behaviour — every endpoint always participates in a weighted vote — keep each endpoint's individual score below the deny threshold (have your service return at most the warning band) and let nio's aggregator do the maths.
OAuth identity sharing
When multiple endpoints share the same OAuth identity (oauth_url + client_id + client_secret), they automatically share a single token cache file under ~/.nio/oauth-cache/<host>-<sha256-fingerprint>.json and a single in-process OAuthAuthStrategy. The /token POST runs only once even when N endpoints fire concurrently. Endpoints with different client_ids get isolated caches. The OAuth client must be pre-registered with the OAuth provider.
When something goes wrong
Auth failures, HTTP errors, timeouts — none of these block the agent's action. They surface through Nio's diagnostics channel: a structured entry in ~/.nio/audit.jsonl, a stderr line, and (on the next hook call) a block of additionalContext that the agent reads. Run /nio doctor to validate every OAuth endpoint with a real client_credentials dry-run.
Use cases
- DLP — your DLP service knows what data classes are in this repo; it can score writes that touch sensitive fields higher.
- Compliance routing — flag commands that touch HIPAA / SOC2 / PCI paths even if they look benign.
- Org allowlists — your internal trusted-domain list, validated per-request rather than baked into config.
- Audit trail signing — sign the decision with your KMS so the audit log is non-repudiable.