External API scoring
An external API judge or enrichment does not call an LLM. Instead it makes an HTTP request to a third-party API — a toxicity, sentiment, moderation, grammar, or PII service, or your own scoring microservice — templates the trace’s evidence into the request, and maps a value out of the JSON response. A judge turns that value into a normalized 0–1 score or a category label; an enrichment turns it into typed output fields.
Reach for this whenever a signal you want already exists behind an API and doesn’t need a model prompt: a moderation score, a language detector, a readability grade, a regex-service verdict, or a scoring endpoint your own team owns.
At a glance
| Where | Judges or Enrichments in the sidebar — pick the External API mode |
| Request | A URL + method + content type + body template, with {{vars}} filled from the trace |
| Response | A JSON path (dot/bracket) that selects the value to score or store |
| Auth | Optional — an HTTP API connection (Settings → Connections) supplies a bearer token, header, query key, or basic credential |
| Needs no LLM | The tenant’s LLM connection is irrelevant — the third-party API does the work |
| Egress safety | Every call is SSRF-gated; a public API works out of the box, private/internal hosts aren’t callable |
A public third-party API (a public IP) works with no extra configuration. A private,
loopback, or link-local endpoint — an internal scoring service, a Docker service, localhost — is
not callable; point the judge at a publicly reachable endpoint.
Create an external API judge
Choose the External API mode
Create a new judge and select the External API type. You’ll define an HTTP request and a response mapping instead of a prompt.
Define the request
| Field | Meaning |
|---|---|
| Request URL | The endpoint to call. May embed {{vars}} (percent-encoded when substituted into the URL). |
| Method | GET, POST, PUT, PATCH, or DELETE (default POST). |
| Content type | json (body sent as JSON), form (URL-encoded), or none (no body). |
| Headers | Optional static headers as a JSON object; values may embed {{vars}}. |
| Body template | A JSON object. Only string leaves are templated, so a numeric literal stays a number and a substituted value can never break the request envelope. |
| Auth connection | Optional — an HTTP API connection for authenticated calls. Leave as None (no auth) for a keyless public API. |
Map the response to a score or label
A judge reads one value from the JSON response, selected by a dot/bracket path:
| Response setting | Used when | Meaning |
|---|---|---|
score_path | The judge’s output is numeric (a 0–1 score or a numeric range). | Path to a number in the response. |
label_path | The judge’s output is categorical (a set of labels). | Path to a string in the response. |
reason_path | Optional. | Path to an explanation string stored as the score’s reason. |
label_map | Optional. | In score mode, map a returned string to a number, e.g. { "pos": 1.0, "neg": 0.0 }. In label mode, rename the raw value to a friendly label (case-insensitive), e.g. { "true": "contains_profanity", "false": "clean" } — an unmapped value is kept verbatim, so a partial map never drops a target. |
invert | Optional (score mode). | Store 1 − value — for a “lower is better” API like a toxicity score. |
Whether the judge reads score_path or label_path follows the judge’s
output spec — a categorical spec reads label_path, a numeric spec reads
score_path.
Preview and test-run before you save
The judge form shows a Live preview panel that renders the exact HTTP request this judge would send — method, URL, headers, and body — hydrated on a real sample trace, with a Sample trace picker to switch samples. The auth credential is never shown, even when a connection is attached.
Click Test run to call the API once against the sample, map the response through your
response config and output spec, and see the resulting score or label, the pass/fail verdict, and
the raw response — all without saving the judge or writing a score. A slow, blocked, or unmappable
response reports an honest error instead of a fabricated verdict. The preview posts to
POST /judges/preview and the test call to POST /judges/test-run; both are read-only and persist
nothing.
Deploy it
Deploy the judge and pick a trigger, the same as any judge. On each run Neens calls the endpoint once per target and writes a score per response.
Worked example — sentiment (no auth)
This judge calls the public text-processing.com sentiment API — no key required — and grades the agent’s answer as a category. It is seeded, enabled, in the demo as Answer Sentiment (external API), so you can open it and click Run.
{
"url": "https://text-processing.com/api/sentiment/",
"method": "POST",
"content_type": "form",
"body_template": { "text": "{{output}}" },
"response": { "label_path": "label" },
"output_spec": {
"type": "categorical",
"labels": ["neg", "neutral", "pos"],
"passing": ["neutral", "pos"],
"higher_is_better": true
},
"target_type": "session"
}The API replies with {"label": "pos", "probability": {...}}; label_path: "label" selects
"pos", and the categorical output spec marks neutral/pos as passing.
Worked example — toxicity (with auth)
This judge calls Google’s Perspective API, which returns a 0–1 toxicity score and needs an API key. It is seeded, enabled, in the demo as Response Toxicity (Perspective), wired to the Perspective API (toxicity) HTTP API connection — add a key to that connection and it runs live.
{
"url": "https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze",
"method": "POST",
"content_type": "json",
"connection_id": "conn-httpapi-perspective-…",
"body_template": {
"comment": { "text": "{{output}}" },
"requestedAttributes": { "TOXICITY": {} }
},
"response": {
"score_path": "attributeScores.TOXICITY.summaryScore.value",
"invert": true
},
"target_type": "session"
}score_path plucks the nested toxicity value; invert: true stores 1 − toxicity, so a
high score means a clean answer.
Create an external API enrichment
An enrichment works the same way but writes typed output fields instead
of a score. In place of response, an enrichment maps each output field to a response path via
field_paths:
{
"url": "https://text-processing.com/api/sentiment/",
"method": "POST",
"content_type": "form",
"body_template": { "text": "{{output}}" },
"field_paths": {
"sentiment": "label",
"positive_score": "probability.pos"
}
}Each key in field_paths is an output-field key you declared on the enrichment; its value is
the response path. A field whose path resolves to null/absent is simply not
written for that target (an honest miss, never a fabricated value). This example is seeded, enabled,
in the demo as Response Sentiment (external API) with a sentiment enum field and a
positive_score number field.
Template variables
String values in the URL, headers, and body template may embed these {{vars}},
resolved from the target trace/session at run time:
| Variable | Value |
|---|---|
{{input}} | The user’s request, as plain text — the parsed exchange, not a raw span body. Empty when the trace carries no recoverable user turn. |
{{output}} | The agent’s user-facing reply, as plain text. Empty when the agent never replied (a guardrail block, a tool-only run) — a routing or planning payload is never promoted to “the answer”. |
{{messages}} | The full message list, JSON-encoded — every span with its raw body, for an endpoint that wants the whole trajectory. |
{{metadata}} | The trace/session metadata, JSON-encoded. |
{{system_prompt}} | The system prompt, when captured. |
{{target_id}} | The id of the target being scored. |
An unknown variable renders as an empty string. Non-string evidence (messages, metadata) is
JSON-encoded so it drops cleanly into a body. Each variable is truncated to 50,000 characters
before templating, so an enormous transcript can’t blow past an endpoint’s input limit.
Response paths
A response path selects a value out of the parsed JSON body using dots for object keys and brackets for list indices:
| Path | Selects |
|---|---|
$ | The whole response body — for an API that replies with a bare scalar at the root (true, 0.87, "toxic"). |
label | The top-level label key. |
$.label | The same top-level label key — $. is an explicit-root alias. |
probability.pos | body["probability"]["pos"]. |
attributeScores.TOXICITY.summaryScore.value | A deeply nested value. |
detected[0].language | The first list element’s language. |
results[-1].score | The last list element’s score. |
A missing path resolves to nothing — the judge records that one target as an honest failure and the enrichment simply omits that field, rather than inventing a value.
Authenticate with an HTTP API connection
For an endpoint that needs a credential, create an HTTP API connection under Settings →
Connections and reference it from the definition by connection_id. The connection holds the
encrypted credential and the auth style; the definition never contains the secret. Supported auth
types (set on the connection):
| Auth type | Where the credential goes |
|---|---|
bearer | Authorization: Bearer <credential> header. |
header | A header you name (header_name), e.g. X-API-Key: <credential>. |
query | A query parameter you name (query_param), e.g. ?key=<credential> (Perspective-style). |
basic | HTTP Basic — username on the connection, credential as the password. |
none | No auth (a keyless public API). |
A referenced connection must be visible to the judge/enrichment’s agent — the same company/org/agent scoping the Settings UI applies. A missing, out-of-scope, or wrong-type connection makes the run fail with a clear error instead of silently falling back to an unauthenticated call, so a stored definition can never pull in another agent’s credential.
How it works
- On each run Neens builds the request per target, applies the connection’s auth, and makes one
HTTP call per target.
follow_redirectsis off, so a redirect can’t bounce the call to an internal host after the safety check. - The response body is buffered up to 4 MiB and parsed as JSON; a non-JSON success is handed back as raw text so a path against a string still works.
- Each call is bounded to 30 seconds. A slow, oversized, blocked, or erroring endpoint fails that one target with a classified error — never a fabricated score or output.
- Judge results flow through the same persistence path as any judge, so scores appear in Scores, on the Traces page, and in dashboards. Enrichment values become filterable columns like any enrichment.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every target errors with “url is not allowed” | The endpoint resolves to a private/loopback IP. | Point the judge at a publicly reachable endpoint — private, loopback, and internal hosts aren’t callable. |
| ”connection … is not configured or not visible” | The connection_id points at a missing or out-of-scope connection. | Recreate the HTTP API connection in the same agent’s scope. |
| Credential could not be resolved | The connection references a secret:// key with no matching environment variable. | Set the expected NEENS_SECRET_<NAME> variable (e.g. NEENS_SECRET_PERSPECTIVE_API_KEY). |
| Targets fail with a missing value | The score_path/label_path/field_paths path doesn’t match the response shape. | Inspect the API’s real response and correct the path. |
See also the Judges and Enrichments guides for the shared run, trigger, and scoring mechanics.