Evaluations
Test agents against fixed cases, fail CI on regressions, split traffic between versions, and collect human feedback. All of it is on the same REST API the dashboard uses.
Eval suites
A suite is a named list of cases for one agent. A case has a name, an input string, and an expected string and/or an assert object (e.g. {"contains":"1945"}, or {"grounded":true} to fail outputs that claim actions nothing backed). Suites are upserted by (tenant, agent, name).
Create or update a suite
POST /v1/eval-suites
{
"agentName": "research-agent",
"name": "factual-accuracy",
"cases": [
{
"name": "case-1",
"input": "What year did WWII end?",
"expected": "1945",
"assert": { "contains": "1945" }
}
]
}
Response: 200 OK
{ "id": "suite-uuid", "agentName": "research-agent", "name": "factual-accuracy" }Other suite endpoints
GET /v1/eval-suites — list all suites (bare array)
GET /v1/eval-suites?agentName= — filter by agent
GET /v1/eval-suites/{id} — get suite
DELETE /v1/eval-suites/{id} — delete suiteRecording a run + CI gate
Run the agent on each case yourself (or use lantern test), then post the per-case results. The server computes the score (mean of case scores) and compares it with the branch baseline. A regression returns HTTP 422, which fails a CI step.
POST /v1/eval-runs
{
"suiteId": "suite-uuid",
"agentVersion": "v2",
"commitSha": "abc1234",
"branch": "main",
"durationMs": 4200,
"totalCostUsd": 0.12,
"caseResults": [
{ "name": "case-1", "passed": true, "score": 0.95, "actual": "1945" }
]
}
Response: 200 OK — no regression
{ "id": "run-uuid", "passed": true, "score": 0.95, "casesTotal": 1,
"casesPassed": 1, "regressed": false, "baselineScore": 0.94 }
Response: 422 Unprocessable Entity — regressed vs. baseline
{ "id": "run-uuid", "score": 0.91, "regressed": true, "baselineScore": 0.94, ... }Other eval-run endpoints
GET /v1/eval-runs?suiteId=&agentName=&branch= — list runs (bare array)Baselines
Pin an eval run as the baseline for an agent and branch. Later runs on that branch are compared against it. From the CLI: lantern test --agent a --suite s --set-baseline.
POST /v1/eval-baselines
{ "agentName": "research-agent", "branch": "main", "evalRunId": "run-uuid" }
GET /v1/eval-baselines?agentName=research-agent&branch=mainEval observability
Two read-only endpoints over existing eval_runs data for the dashboard quality-trend sparkline and top-failing-cases view.
Failure clusters
GET /v1/eval-observability/failures?agentName=research-agent&branch=main&limit=50
Response:
{
"clusters": [
{
"case": "What year did WWII end?",
"failures": 3,
"seen": 5,
"failRate": 0.6,
"sampleError": "Agent said 1944 instead of 1945",
"firstSeen": "2026-07-01T...",
"lastSeen": "2026-07-20T..."
}
],
"runsScanned": 50
}Score trend
GET /v1/eval-observability/trends?agentName=research-agent&branch=main
Response:
{
"points": [
{
"runId": "run-uuid",
"createdAt": "2026-07-01T...",
"score": 0.94,
"passRate": 0.9,
"passed": 9,
"costUsd": 0.12,
"agentVersion": "v1",
"commitSha": "abc1234"
}
],
"latestVsMean": -0.03,
"regressing": true
}A/B experiments
Split traffic between two agent versions. The split is deterministic (FNV-1a hash of the run ID), so a run always lands on the same variant. Record a score per run; with autoPromote on, Lantern promotes the winner once both arms have enough runs and one leads by more than 0.02. This is a fixed margin, not a significance test.
Create an experiment
POST /v1/experiments
{
"agentName": "research-agent",
"name": "prompt-v2-test",
"variantAVersion": "v1",
"variantBVersion": "v2",
"trafficSplitB": 20, // percent of traffic to B (0-100)
"autoPromote": true, // promote the leader at +0.02 score
"minRunsToPromote": 100 // per arm; default 100
}
Response: 201 Created
{ "id": "exp-uuid", "status": "running", ... }Record an outcome
POST /v1/experiments/{id}/record
{
"variant": "b", // "a" | "b"
"score": 0.95 // 0-1; the stored score is a running mean per variant
}
// Auto-promotion fires when:
// - both arms have >= minRunsToPromote runs
// - one arm's mean score beats the other by more than 0.02Conclude manually
POST /v1/experiments/{id}/conclude
{
"winner": "b", // "a" | "b" | "tie"
"promote": true // flip the agent's current version to the winner
}Other experiment endpoints
GET /v1/experiments — list (bare array)
GET /v1/experiments/{id} — get with aScore / bScore and run countsHuman feedback (RLHF)
Collect thumbs-up / thumbs-down reactions on individual runs. Score is 1–5; 4–5 is positive, 1–2 is negative. Runs scored 2 or lower are pulled in by rehearsals.
POST /v1/runs/{id}/feedback
{
"score": 2,
"comment": "The agent missed the follow-up question",
"preferredOutput": "It should have asked for clarification first"
}
GET /v1/runs/{id}/feedback — per-run history (bare array)
GET /v1/agents/{name}/feedback — aggregate summary
→ { totalFeedback, avgScore, thumbsUp, thumbsDown, last7DaysAvgScore, ... }Rehearsals
Pull past failed or low-scored runs (feedback score of 2 or lower) as synthetic test cases, then replay them against a candidate version before you flip traffic. The endpoint returns the cases; lantern test --rehearse runs them.
POST /v1/runs/rehearse
{
"agentName": "research-agent",
"window": "7d", // look-back (default 7d)
"limit": 20, // default 25, max 200
"includeFailures": true, // status=failed runs (default true)
"includeLowScore": true // feedback score <= 2 (default true)
}
Response: { "agentName", "window", "cases": [{ "originalRunId", "input",
"expectedOutput", ... }], "count", "reason"? }OTel traces
Every HTTP request and gRPC call carries a span enriched with lantern.tenant_id, lantern.run_id, lantern.step_id, and lantern.user_id via middleware.EnrichSpan. Traces are no-op safe when LANTERN_OTEL_ENABLED is unset. Export to any OTel backend by setting OTEL_EXPORTER_OTLP_ENDPOINT.