Skip to main content
For checks that need your own logic - a proprietary classifier, a business rule that needs data outside the trace, anything code can decide that a phrase/regex/semantic pattern can’t - two scorer kinds run YOUR code against a sample of live traffic:
  • An external scorer POSTs each sampled trace to your own HTTP endpoint and uses its verdict.
  • A code scorer runs a script you write (Python or JavaScript) inside the engine itself - no endpoint to host.
Like LLM judge scorers, both are standalone entities on the Scorers page with their own sample rate, agent scope, enable/pause toggle, and severity. (The SDK/API resource name for both remains custom-evaluators.)

Code scorers

Scorers → New scorer → Code scorer. The script defines one function:
The JavaScript variant is identical in shape (async function handler(...), await trace.getSpans({ spanType: ["llm"] })). A returned score below the scorer’s alert threshold (default 0.5) raises a signal; every scored check is recorded in the scorer’s event history either way, and None/null skips the trace entirely. trace.get_spans() returns the trace’s own span subtree (root first, start-time ordered). Each span carries span_id, parent_span_id, name, input, output, error, model, latency_ms, input_tokens, output_tokens, tool_calls, metadata, started_at, and a derived type: llm (a model is recorded), tool (tool calls recorded), retrieval (metadata.kind == "retrieval"), or span (anything else). Dry run in the dialog executes the script against a synthetic two-span sample (one root, one llm span) and shows the score it returns.

External scorers

Request (engine → your endpoint, POST, JSON, schemaVersion: 2):
Everything the tracer records rides along: the full root record under trace (the v1 keys - input/output/error/toolCalls - are exactly where they always were, so v1 endpoints keep working untouched), and the trace’s span subtree under spans with the same per-span fields and derived type values code scorers see (llm/tool/retrieval/span).
Two things endpoint authors trip on: agentId is AgentX’s internal agent id, never the agent’s name (fetch the agent list once rather than string-comparing names). And evaluatorId/agentId/traceId are all null in a dry run - the first payload your endpoint will ever receive - so handle nulls before assuming strings.
Response your endpoint must return:
matches is the only field that decides anything. reason and score are both optional and purely informational - recorded and shown alongside the resulting signal, but neither affects whether one is raised. A minimal example endpoint (Flask):

Setup and behavior

Build it from the dashboard: Scorers → New scorer → External scorer. The dialog documents the request/response contract in place, and Dry run sends a synthetic v2 payload to your URL so you can confirm it responds correctly before saving.
Distinct from monitor_profiles.channels"webhook:<url>" notification targets - those fire-and-forget a message after a signal is raised and never consume a response. A custom evaluator’s response is the detection result, awaited synchronously (up to the 8-second timeout).
Dashboard-only for now - no SDK method exists yet. The REST API works directly if you want to script it: full CRUD on /agent-monitoring/custom-evaluators[/:id] (code scorers pass {"kind": "code", "language": "python"|"javascript", "script": "...", "alertBelow": 0.5}), plus POST /agent-monitoring/custom-evaluators/dry-run (transient - {"url": ...} for external, {"kind": "code", "language": ..., "script": ...} for code - the live response back out, nothing persisted).