Grade AI output from your own scripts
Send the task an AI agent or model was given and the output it produced — code, a report, an analysis, an email, structured data, anything text — and get back one JSON object: an honest ship / revise / rework verdict, a 0–100 weighted score, a scorecard across five evaluation dimensions, a rubric scored criterion by criterion (yours, or one derived from the task), findings ranked by severity each quoting the output they concern, and a full refined version of the output that actually satisfies the task. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire the evaluation into an agent loop that retries until it ships, a nightly regression harness over a prompt suite, or a CI gate that refuses a generated artifact scoring under your bar. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api, app slug
eval-forge. Every request sends
Authorization: Bearer <token> and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on failure.
The evaluation itself is produced by the gpt-terra model. Estimates are
free; runs are metered against your credit balance. There is a single run task — one
task-and-output pair in, one evaluation out, no follow-up calls and no session state to
carry.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest evaluating a very large output). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1 — read it from your shell environment in real code
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
raise RuntimeError(payload.get("error", {}).get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered evaluation
runs billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your
clipboard, which every example below reads. Treat the token like a password: it can spend
your credits. For fully headless scripts, POST /guest mints a guest token with
no browser involved.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"eval-forge"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "eval-forge"})["token"]
const { token } = await api("POST", "/guest", { slug: "eval-forge" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "eval-forge"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"eval-forge"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "eval-forge" })["token"]
$token = api("POST", "/guest", ["slug" => "eval-forge"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "eval-forge" });
var token = guest.GetProperty("token").GetString();
The app stores this browser's token under the localStorage key
skillsafe_app_token:eval-forge, on the app's own origin. The
token page reads and manages it for you — you never need
to open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Check this before evaluating
a long output.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — Estimate the cost
Send exactly the input you would send to /run; the response's
hold_credits is the worst-case cost. Nothing is charged and no job is created,
so estimating is free — useful when you are feeding in a long generated report or a
whole batch of agent transcripts and want a ceiling before spending credits.
| Input field | Type | Notes |
|---|---|---|
task | string, required | The instruction the AI was given — the prompt, ticket, brief or spec — up to 20000 characters. Every requirement stated or clearly implied in it becomes an evaluation criterion. Longer text is clipped middle-out, with a [... clipped ...] marker showing where. |
output | string, required | The AI output to evaluate, verbatim, up to 100000 characters — do not clean it up first, since the placeholder markers, boilerplate and truncation are exactly what is being judged. Longer text is clipped middle-out with the same [... clipped ...] marker. |
rubric | string, optional | Your own criteria, one per line, up to 4000 characters. When present these are authoritative: every line is scored exactly as written, in the order you gave, in rubric_results — none dropped, none added. When empty, the evaluator derives 5–8 concrete criteria from the task itself and scores those. |
reference | string, optional | Ground truth to check the output against, up to 30000 characters: the source material the output was supposed to be grounded in, an expected result, a spec, sample data. When present, any claim in the output that contradicts or is absent from the reference is treated as a grounding problem — a fabricated number becomes a high finding rather than an unverifiable one. |
kind | string | code | document | data | unknown — what the output is supposed to be. The evaluation is calibrated to it: code means correctness is read line by line for bugs, missing edge cases and mismatches with the task's API or language demands; document (report, analysis, email, docs) means factual claims must be supported and the structure the task asked for must be present; data (JSON, CSV, YAML, tables) means the syntax must parse and the schema, fields and counts must match what the task demanded, checked mechanically; on unknown the evaluator infers from the task and the output and says which it assumed. |
prescan_facts | object | What the app's free client-side prescan mechanically detected: {"antipatterns": [], "items": [], "signals": {}}. antipatterns and items hold {id, label, lines} entries — pattern-matched output smells (ap:todo-marker, ap:template-var, ap:lorem, ap:ai-boilerplate, ap:hedge-cluster, ap:broken-json, ap:unclosed-fence, ap:empty-section, ap:repeated-paragraph, ap:truncated-ending, ap:suspect-link) and the requirement-shaped lines pulled out of the task (r:1 through r:12), each with the line numbers it was seen on. signals is a counter object: {"task_words": 0, "output_words": 0, "output_lines": 0, "code_blocks": 0, "headings": 0, "list_items": 0, "links": 0, "requirements": 0, "rubric_criteria": 0}. Every id you send comes back in coverage_check. The web UI fills this from its own scan; API callers may send an empty object {"antipatterns": [], "items": [], "signals": {}}. |
retry_note | string, optional | Only set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out. |
TASK='Write a two-sentence summary of the release notes for the changelog, and list any breaking changes as bullets.'
OUTPUT='Version 3.2 adds dark mode and makes search about 40 percent faster.
TODO: mention the API change.'
jq -n --arg task "$TASK" --arg output "$OUTPUT" \
'{task: $task, output: $output, rubric: "", reference: "", kind: "document",
prescan_facts: {antipatterns: [], items: [], signals: {}}}' > input.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data.hold_credits'
TASK = ("Write a two-sentence summary of the release notes for the changelog, "
"and list any breaking changes as bullets.")
OUTPUT = ("Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n"
"TODO: mention the API change.")
payload = {
"task": TASK,
"output": OUTPUT,
"rubric": "",
"reference": "",
"kind": "document",
"prescan_facts": {"antipatterns": [], "items": [], "signals": {}},
}
est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const task =
"Write a two-sentence summary of the release notes for the changelog, " +
"and list any breaking changes as bullets.";
const output =
"Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n" +
"TODO: mention the API change.";
const payload = {
task,
output,
rubric: "",
reference: "",
kind: "document",
prescan_facts: { antipatterns: [], items: [], signals: {} },
};
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const task = "Write a two-sentence summary of the release notes for the changelog, " +
"and list any breaking changes as bullets."
const output = "Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n" +
"TODO: mention the API change."
payload := map[string]any{
"task": task,
"output": output,
"rubric": "",
"reference": "",
"kind": "document",
"prescan_facts": map[string]any{
"antipatterns": []any{}, "items": []any{}, "signals": map[string]any{},
},
}
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String task = "Write a two-sentence summary of the release notes for the changelog, "
+ "and list any breaking changes as bullets.";
String output = "Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n"
+ "TODO: mention the API change.";
String jsonPayload = """
{"task": %s, "output": %s,
"rubric": "", "reference": "", "kind": "document",
"prescan_facts": {"antipatterns": [], "items": [], "signals": {}}}
""".formatted(toJsonString(task), toJsonString(output));
String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
TASK_TEXT = "Write a two-sentence summary of the release notes for the changelog, " \
"and list any breaking changes as bullets."
OUTPUT_TEXT = "Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n" \
"TODO: mention the API change."
payload = { task: TASK_TEXT, output: OUTPUT_TEXT,
rubric: "", reference: "", kind: "document",
prescan_facts: { antipatterns: [], items: [], signals: {} } }
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$task = "Write a two-sentence summary of the release notes for the changelog, "
. "and list any breaking changes as bullets.";
$output = "Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n"
. "TODO: mention the API change.";
$payload = [
"task" => $task,
"output" => $output,
"rubric" => "",
"reference" => "",
"kind" => "document",
"prescan_facts" => ["antipatterns" => [], "items" => [], "signals" => new stdClass()],
];
$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var task = "Write a two-sentence summary of the release notes for the changelog, "
+ "and list any breaking changes as bullets.";
var output = "Version 3.2 adds dark mode and makes search about 40 percent faster.\n\n"
+ "TODO: mention the API change.";
var payload = new {
task,
output,
rubric = "",
reference = "",
kind = "document",
prescan_facts = new {
antipatterns = Array.Empty<object>(), items = Array.Empty<object>(),
signals = new { },
},
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");
prescan_facts is how you make the evaluation answer for things you already know
about. Send {"antipatterns": [{"id": "ap:todo-marker", "label": "TODO / FIXME /
bracketed placeholder left in the output", "lines": [3]}], "items": [{"id": "r:1", "label":
"list any breaking changes as bullets", "lines": [1]}], "signals": {"task_words": 20,
"output_words": 17, "output_lines": 3, "code_blocks": 0, "headings": 0, "list_items": 0,
"links": 0, "requirements": 1, "rubric_criteria": 0}} and every one of those ids
comes back in coverage_check — addressed, or explained away as a false
positive (a "TODO" inside a code comment the task itself asked for is not a defect, and an
unresolved {{name}} in output that is supposed to be a template is correct).
Nothing you flag is silently dropped.
Step 4 — Run the evaluation and wait for the result
/run takes the same input as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds
until status is succeeded or failed (a run typically
takes 30–90 s, since the refined output is written out in full). Always send an
Idempotency-Key header so a network retry can't start a second,
double-charged run. The evaluation is in output — usually nested as
output.output, and as a JSON string, so parse defensively. The samples
below print the evaluation name, verdict and score, the five scorecard dimensions, the
rubric results and the findings, then write refinement.text to disk using
refinement.filename.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: eval-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
# unwrap the evaluation once, then read it
echo "$JOB" | jq -r '.data.output.output' > evaluation.json
jq -r '
"\(.eval_name) [\(.verdict_level)] \(.score)/100: \(.verdict)",
"",
"SCORECARD",
(.scorecard[] | " [\(.status)] \(.score)/5 \(.dimension) - \(.note)"),
"",
"RUBRIC",
(.rubric_results[] | " [\(.status)] \(.criterion) - \(.note)"),
"",
"FINDINGS",
(.findings[] | " (\(.severity)) \(.category): \(.title)")' evaluation.json
# and drop the refined output straight onto disk
jq -r '.refinement.text' evaluation.json > "$(jq -r '.refinement.filename' evaluation.json)" # refined.md
import time
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": "eval-001"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
evaluation = json.loads(raw) if isinstance(raw, str) else raw
print(f'{evaluation["eval_name"]} [{evaluation["verdict_level"]}] '
f'{evaluation["score"]}/100: {evaluation["verdict"]}')
for d in evaluation["scorecard"]:
print(f' [{d["status"]:>4}] {d["score"]}/5 {d["dimension"]:<26} {d["note"]}')
for r in evaluation["rubric_results"]:
print(f' [{r["status"]:>7}] {r["criterion"]}')
for f in evaluation["findings"]:
print(f' ({f["severity"]}) {f["category"]}: {f["title"]}')
if f["fix_text"]:
print(f' {f["fix_text"]}')
for c in evaluation["coverage_check"]:
print(f' {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')
with open(evaluation["refinement"]["filename"], "w", encoding="utf-8") as fh: # refined.md
fh.write(evaluation["refinement"]["text"])
import { writeFileSync } from "node:fs";
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": crypto.randomUUID() });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output;
const evaluation = typeof raw === "string" ? JSON.parse(raw) : raw;
console.log(`${evaluation.eval_name} [${evaluation.verdict_level}] ` +
`${evaluation.score}/100: ${evaluation.verdict}`);
for (const d of evaluation.scorecard) {
console.log(` [${d.status}] ${d.score}/5 ${d.dimension}: ${d.note}`);
}
for (const r of evaluation.rubric_results) {
console.log(` [${r.status}] ${r.criterion}: ${r.note}`);
}
for (const f of evaluation.findings) {
console.log(` (${f.severity}) ${f.category}: ${f.title}`);
if (f.fix_text) console.log(` ${f.fix_text}`);
}
for (const c of evaluation.coverage_check) {
console.log(` ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}
writeFileSync(evaluation.refinement.filename, evaluation.refinement.text); // refined.md
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output json.RawMessage `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
// job.Output is {"output": "<json string>"} — unwrap, unquote, then unmarshal:
type Evaluation struct {
EvalName string `json:"eval_name"`
VerdictLevel string `json:"verdict_level"`
Verdict string `json:"verdict"`
Score int `json:"score"`
Scorecard []struct {
Dimension, Status, Note string
Score int
} `json:"scorecard"`
Findings []struct {
Severity, Category, Title, Detail string
FixText string `json:"fix_text"`
} `json:"findings"`
RubricResults []struct {
Criterion, Status, Note string
} `json:"rubric_results"`
Refinement struct {
Filename, Text string
} `json:"refinement"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var evaluation Evaluation
json.Unmarshal([]byte(wrapper.Output), &evaluation)
fmt.Printf("%s [%s] %d/100: %s\n", evaluation.EvalName, evaluation.VerdictLevel,
evaluation.Score, evaluation.Verdict)
for _, d := range evaluation.Scorecard {
fmt.Printf(" [%s] %d/5 %s: %s\n", d.Status, d.Score, d.Dimension, d.Note)
}
for _, r := range evaluation.RubricResults {
fmt.Printf(" [%s] %s: %s\n", r.Status, r.Criterion, r.Note)
}
for _, f := range evaluation.Findings {
fmt.Printf(" (%s) %s: %s\n", f.Severity, f.Category, f.Title)
}
os.WriteFile(evaluation.Refinement.Filename, []byte(evaluation.Refinement.Text), 0o644) // refined.md
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;
while (true) {
String job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// The evaluation is at data.output.output as a JSON string — parse it again, then read
// eval_name, verdict_level, verdict, overview, score, scorecard[] (five dimensions with
// dimension/status/score/note), findings[] (severity/category/title/detail/fix_text),
// rubric_results[] (criterion/status/note), coverage_check[] (id/addressed/note),
// refinement{filename, text}, next_steps[] and summary.
// Finally write the refined output to disk:
// Files.writeString(Path.of(refinementFilename), refinementText); // refined.md
started = api("POST", "/run", payload)
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
evaluation = raw.is_a?(String) ? JSON.parse(raw) : raw
puts "#{evaluation["eval_name"]} [#{evaluation["verdict_level"]}] " \
"#{evaluation["score"]}/100: #{evaluation["verdict"]}"
evaluation["scorecard"].each { |d| puts " [#{d["status"]}] #{d["score"]}/5 #{d["dimension"]}: #{d["note"]}" }
evaluation["rubric_results"].each { |r| puts " [#{r["status"]}] #{r["criterion"]}: #{r["note"]}" }
evaluation["findings"].each do |f|
puts " (#{f["severity"]}) #{f["category"]}: #{f["title"]}"
puts " #{f["fix_text"]}" unless f["fix_text"].to_s.empty?
end
evaluation["coverage_check"].each { |c| puts " #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }
File.write(evaluation["refinement"]["filename"], evaluation["refinement"]["text"]) # refined.md
$started = api("POST", "/run", $payload);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$evaluation = is_string($raw) ? json_decode($raw, true) : $raw;
echo "{$evaluation['eval_name']} [{$evaluation['verdict_level']}] "
. "{$evaluation['score']}/100: {$evaluation['verdict']}\n";
foreach ($evaluation["scorecard"] as $d) {
echo " [{$d['status']}] {$d['score']}/5 {$d['dimension']}: {$d['note']}\n";
}
foreach ($evaluation["rubric_results"] as $r) {
echo " [{$r['status']}] {$r['criterion']}: {$r['note']}\n";
}
foreach ($evaluation["findings"] as $f) {
echo " ({$f['severity']}) {$f['category']}: {$f['title']}\n";
if ($f["fix_text"] !== "") { echo " {$f['fix_text']}\n"; }
}
foreach ($evaluation["coverage_check"] as $c) {
echo " {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}
file_put_contents($evaluation["refinement"]["filename"], $evaluation["refinement"]["text"]); // refined.md
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1500);
}
var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var evaluation = doc.RootElement;
Console.WriteLine($"{evaluation.GetProperty("eval_name")} " +
$"[{evaluation.GetProperty("verdict_level")}] " +
$"{evaluation.GetProperty("score")}/100: {evaluation.GetProperty("verdict")}");
foreach (var d in evaluation.GetProperty("scorecard").EnumerateArray())
{
Console.WriteLine($" [{d.GetProperty("status")}] {d.GetProperty("score")}/5 " +
$"{d.GetProperty("dimension")}: {d.GetProperty("note")}");
}
foreach (var r in evaluation.GetProperty("rubric_results").EnumerateArray())
{
Console.WriteLine($" [{r.GetProperty("status")}] {r.GetProperty("criterion")}: {r.GetProperty("note")}");
}
foreach (var f in evaluation.GetProperty("findings").EnumerateArray())
{
Console.WriteLine($" ({f.GetProperty("severity")}) {f.GetProperty("category")}: " +
$"{f.GetProperty("title")}");
}
var refinement = evaluation.GetProperty("refinement");
await File.WriteAllTextAsync(refinement.GetProperty("filename").GetString()!, // refined.md
refinement.GetProperty("text").GetString()!);
The model is asked for one JSON object and nothing else, but a stray code fence or preamble
is always possible. Strip a leading ```json fence, take the text between the
first { and the last }, and only then parse — that is what
the app does before it falls back to a retry_note reformat run.
The evaluation object — output schema
One JSON object, always the same shape. Every array is present (findings is
empty only if genuinely nothing applies); scorecard always has exactly the five
dimensions, rubric_results always has at least three criteria, and
refinement.text is never empty. If the paste was too thin to evaluate
responsibly — a one-line task and a one-line output — you still get this object:
what is there gets evaluated, the verdict says the paste is thin, and what
would be needed lands in next_steps. If output is empty of
substance, or is obviously not an answer to task, you still get the object
— verdict_level rework, one high adherence
finding saying what was received, every rubric criterion fail or
na, and a refinement.text written from scratch against the task,
flagged as such in the verdict.
| Field | Type | Meaning |
|---|---|---|
eval_name | string | A short name for this evaluation, taken from the task's own domain naming. |
verdict_level | string | ship (the output does what the task asked; the refinement is polish, not repair), revise (findings exist but are medium/low — real defects that do not fail the task outright) or rework (a high finding means the output fails the task as delivered: a requirement ignored, a wrong answer, code that cannot run, fabricated facts, broken mandated structure). |
verdict | string | One or two sentences: the overall state and the single most important fix. |
overview | string | One or two paragraphs: what was asked, what was delivered, and the pattern behind what was found. |
scorecard | array of 5 | {dimension, status, score, note} — the five dimensions listed below, each exactly once and in order. status is good (nothing material), risk (works, with caveats) or bad (a high-severity finding lives here); score is an integer 1–5 (5 flawless, 3 real defects, 1 fails the dimension); each note references something concrete in the paste. A dimension a high finding touches is never good and never scores 5. A dimension the paste does not exercise at all — no factual claims to ground, no safety surface — is good with a note saying so, scored 4 rather than 5, since nothing was demonstrated. |
score | number | The weighted overall, 0–100, rounded to an integer: each dimension's 1–5 mapped to 0–100 (score × 20), then weighted Instruction adherence 30%, Correctness & grounding 30%, Completeness & coverage 20%, Clarity & structure 10%, Safety & honesty 10%. |
findings | array | {severity, category, title, detail, fix_text}. severity is high (the output fails the task as delivered — a stated requirement ignored, a wrong answer, code that cannot run or does the wrong thing, a fabricated fact, invalid JSON or a missing mandated section) | medium (it delivers but with real defects — a partially met requirement, an unverifiable specific, misleading framing, error-prone code that happens to work, format drift) | low (polish — wordiness, weak ordering, tone, style the task never stated); category is adherence, correctness, grounding, coverage, clarity, format, safety or style. detail quotes the sentence, claim, line, function or field it concerns; fix_text is the corrected text, code or data in the output's own voice and naming, or an empty string when the finding is a judgement call rather than a mechanical fix. |
rubric_results | array (3+) | {criterion, status, note} — your rubric lines verbatim and in order when you sent one, the evaluator's derived 5–8 criteria otherwise. status is pass (clearly met, with the note pointing at where), partial (attempted but incomplete or flawed — a finding or the note says what is missing), fail (not met — a finding backs it) or na (cannot be judged from what was pasted; the note says why). The note reports what was seen, not what was hoped. |
coverage_check | array | {id, addressed, note} — one entry per prescan_facts fact you sent (ap:todo-marker, ap:broken-json, r:3, …), saying where the evaluation covers it or why it was set aside. A keyword hit can be a false positive — a "TODO" the task itself asked for is not a defect, and a {{name}} in output that is supposed to be a template is correct — and the note says so. Nothing you flagged is silently dropped. |
refinement | object | {filename, text} — the output rewritten to actually satisfy the task: every high and medium finding fixed, every failed rubric criterion met, the structure and format the task demanded, the original's voice, naming and valid content preserved. It is a complete replacement for the pasted output, not a patch or a commentary. Grounding problems are never fixed by inventing a different unsupported specific: fabricated values are replaced with what the reference supports, or with an honest marked gap such as [source needed: launch date]. filename is named for the content — refined.md for prose, the code's natural filename for code, refined.json or refined.csv for data. |
next_steps | string[] | Ordered and concrete: replace the unsupported churn figure with the reference's 4.1%, add the missing rollback section, re-run the agent with the date range pinned in the prompt, and so on. |
summary | string | 3–5 sentences you could paste into a review thread or an eval log. |
The five scorecard dimensions, in order, spelled exactly like this:
| dimension | Weight | What its note covers |
|---|---|---|
Instruction adherence | 30% | Did the output do everything the task asked, and nothing the task forbade — every stated or clearly implied requirement, the format demanded, the constraints honoured. Requirements the task never stated do not count against it. |
Correctness & grounding | 30% | Are the claims true, the code sound, the data valid, everything supported. A specific factual claim the reference contradicts — or that nothing in the task, reference or common knowledge supports — is a high grounding finding and caps this dimension at bad. With no reference given, unsupported specifics are flagged as unverifiable rather than false: grounding, severity medium. |
Completeness & coverage | 20% | Is every part of the task and every input element covered, with no dropped requirement, no half-answered bullet and no section promised and never written. |
Clarity & structure | 10% | Is the output organized, readable and in the format the task wanted — headings and ordering that help, no filler, no chat residue in a deliverable. |
Safety & honesty | 10% | Does it state uncertainty honestly, avoid fabricated authority and invented citations, and contain nothing harmful or leaking. Not exercised by most pastes — when it is not, the honest status is good, scored 4. |
A small, realistic result for the release-notes paste above, trimmed for length:
{
"eval_name": "Release notes changelog summary",
"verdict_level": "rework",
"verdict": "The summary is one sentence where two were asked for and the breaking-changes list
was never written - a 'TODO' stands in its place. Write the breaking-changes bullets
and add the second summary sentence before this goes in the changelog.",
"overview": "The task asked for two things: a two-sentence changelog summary and a bulleted list
of breaking changes. The output delivers one sentence naming dark mode and a search
speed-up, then abandons the second requirement with 'TODO: mention the API change.'
The pattern is a draft shipped as finished - what is written is plausible and on
topic, but half the task is a placeholder, and the '40 percent faster' figure has
nothing behind it in the paste.",
"scorecard": [
{ "dimension": "Instruction adherence", "status": "bad", "score": 1,
"note": "One of the two requirements is unmet and the summary is one sentence, not two;
the 'TODO' line admits the gap in the deliverable itself." },
{ "dimension": "Correctness & grounding", "status": "risk", "score": 3,
"note": "'about 40 percent faster' is a specific number with no reference supplied to
support it - unverifiable rather than known false." },
{ "dimension": "Completeness & coverage", "status": "bad", "score": 1,
"note": "The breaking-changes section is absent; the 'API change' is named but never
described." },
{ "dimension": "Clarity & structure", "status": "risk", "score": 3,
"note": "The one sentence that exists is clear, but a 'TODO:' line in the body is not the
bulleted list the task asked for." },
{ "dimension": "Safety & honesty", "status": "good", "score": 4,
"note": "Nothing harmful or leaking; the 'TODO' is at least an honest admission. Not
otherwise exercised by this paste." }
],
"score": 32,
"findings": [
{ "severity": "high", "category": "adherence",
"title": "The breaking-changes list was never written",
"detail": "The task asks to 'list any breaking changes as bullets'; the output has
'TODO: mention the API change.' in place of the list - a placeholder shipped as
a deliverable.",
"fix_text": "Breaking changes:\n- The /v1/search endpoint now requires a `scope` parameter;
requests without it return 400." },
{ "severity": "high", "category": "adherence",
"title": "The summary is one sentence, not two",
"detail": "'Write a two-sentence summary' is explicit; the output's summary is the single
sentence 'Version 3.2 adds dark mode and makes search about 40 percent faster.'",
"fix_text": "Version 3.2 adds dark mode across the app and makes search noticeably faster.
It also changes the search API, which existing integrations must update." },
{ "severity": "medium", "category": "grounding",
"title": "The 40 percent speed-up is unverifiable",
"detail": "'about 40 percent faster' is a specific measurable claim, and no reference was
supplied that states it; hedging it with 'about' does not make it supported.",
"fix_text": "" }
],
"rubric_results": [
{ "criterion": "The summary is exactly two sentences", "status": "fail",
"note": "One sentence delivered." },
{ "criterion": "Breaking changes are listed as bullets", "status": "fail",
"note": "Replaced by a 'TODO' line; no bullets in the output." },
{ "criterion": "Claims about the release are supported", "status": "partial",
"note": "Dark mode is a plain feature claim; the 40 percent figure is unsupported." },
{ "criterion": "Tone suits a public changelog", "status": "pass",
"note": "Plain, user-facing phrasing with no assistant boilerplate." },
{ "criterion": "No placeholders or unfinished markers remain", "status": "fail",
"note": "'TODO: mention the API change.' is present in the delivered text." }
],
"coverage_check": [
{ "id": "ap:todo-marker", "addressed": true,
"note": "Real defect - it stands in for the required breaking-changes list; first finding." },
{ "id": "r:1", "addressed": true,
"note": "'list any breaking changes as bullets' - scored as failed in rubric_results." }
],
"refinement": { "filename": "refined.md",
"text": "Version 3.2 adds dark mode across the app and makes search
noticeably faster. It also changes the search API, which existing
integrations must update.\n\nBreaking changes:\n\n- …" },
"next_steps": [
"Write the breaking-changes bullets: name the endpoint, the change and what callers must do.",
"Expand the summary to the two sentences the task asked for.",
"Either cite the benchmark behind '40 percent faster' or drop the number."
],
"summary": "Half the task is missing: the breaking-changes list is a 'TODO' and the summary is
one sentence instead of two. What was written is on topic and readable, but the one
measurable claim in it - a 40 percent search speed-up - has nothing supporting it. …"
}
The refined output is a starting point, not a sign-off: it is written to be complete and self-consistent with the findings, but it is AI-generated and it only sees what you pasted. Read it, run it through whatever the content deserves — a compiler and your test suite for code, a schema validator for data, a fact check against your own sources for prose — and keep the human review in the loop before it goes anywhere near production. An evaluator that cannot see your ground truth cannot certify it.
Step 5 — Stream the evaluation as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events, so you can show progress instead of a spinner — useful here
because the refined output makes for a long reply. This app's own progress panel is this
endpoint. Events are separated by a blank line; each has an event: line and a
data: line carrying JSON.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). |
done | {job_id, status, charged_credits, output} | The final, authoritative result — read the evaluation from output.output rather than trusting concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: eval-$(date +%s)" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"eval_name\":\"Release notes"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":548,"output":{"output":"{...}"}}
import json, requests
result = None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": "eval-001"},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(data.get("message", "run failed"))
evaluation = json.loads(result["output"]["output"]) # authoritative
print("charged:", result["charged_credits"], "-", evaluation["eval_name"],
evaluation["score"], "/100")
for d in evaluation["scorecard"]:
print(f' [{d["status"]}] {d["score"]}/5 {d["dimension"]}')
open(evaluation["refinement"]["filename"], "w", encoding="utf-8").write(
evaluation["refinement"]["text"])
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !body) continue;
const data = JSON.parse(body);
if (name === "delta") process.stdout.write("."); // live progress
if (name === "done") done = data;
if (name === "error") throw new Error(data.message ?? "run failed");
}
}
const evaluation = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${evaluation.eval_name} ${evaluation.score}/100`);
for (const d of evaluation.scorecard) console.log(` [${d.status}] ${d.score}/5 ${d.dimension}`);
writeFileSync(evaluation.refinement.filename, evaluation.refinement.text); // refined.md
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "eval-001")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatal(data["message"])
}
}
}
// final["output"].(map[string]any)["output"].(string) is the evaluation JSON —
// unmarshal it into the Evaluation struct from step 4, then write
// evaluation.Refinement.Text to disk.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "eval-001")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) System.out.print("."); // live progress
else if ("done".equals(event)) done = data;
else if ("error".equals(event)) throw new RuntimeException(data);
}
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// eval_name, verdict_level, score, scorecard[], findings[], rubric_results[],
// refinement{filename, text} and the rest.
require "net/http"
require "json"
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "eval-001"
req.body = payload.to_json
event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print "." # live progress
when "done" then done = data
when "error" then raise (data["message"] || "run failed")
end
end
end
end
end
end
evaluation = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{evaluation["eval_name"]} #{evaluation["score"]}/100"
evaluation["scorecard"].each { |d| puts " [#{d["status"]}] #{d["score"]}/5 #{d["dimension"]}" }
File.write(evaluation["refinement"]["filename"], evaluation["refinement"]["text"]) # refined.md
$event = null;
$done = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: eval-001",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { echo "."; } // live progress
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$evaluation = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$evaluation['eval_name']} {$evaluation['score']}/100\n";
foreach ($evaluation["scorecard"] as $d) { echo " [{$d['status']}] {$d['score']}/5 {$d['dimension']}\n"; }
file_put_contents($evaluation["refinement"]["filename"], $evaluation["refinement"]["text"]); // refined.md
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "eval-001");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta") Console.Write("."); // live progress
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var evalDoc = JsonDocument.Parse(text!);
var evaluation = evalDoc.RootElement;
Console.WriteLine($"{evaluation.GetProperty("eval_name")} {evaluation.GetProperty("score")}/100");
foreach (var d in evaluation.GetProperty("scorecard").EnumerateArray())
Console.WriteLine($" [{d.GetProperty("status")}] {d.GetProperty("score")}/5 {d.GetProperty("dimension")}");
var refinement = evaluation.GetProperty("refinement");
await File.WriteAllTextAsync(refinement.GetProperty("filename").GetString()!, // refined.md
refinement.GetProperty("text").GetString()!);
In a browser, the native EventSource only speaks GET, and this endpoint is a
POST — read the fetch response body incrementally, as the JavaScript
sample above does. On an idempotent replay the server may answer with a plain JSON
envelope instead of an event stream; check the Content-Type before you start
parsing frames.
Step 6 — Gate CI on the verdict
The CI gate promised at the top of this page is one exit code away from step 4: after
evaluation.json is on disk, refuse the artifact when the verdict is
rework or the weighted score misses your bar. Derive the
Idempotency-Key from a hash of the artifact under test (as below) rather than a
timestamp, so a re-run of the same CI job replays the same evaluation instead of billing a
second one.
# step 4 wrote evaluation.json; this refuses the artifact when it misses the bar.
# Use an input-derived Idempotency-Key in step 4, e.g.:
# -H "Idempotency-Key: eval-$(sha256sum input.json | cut -c1-16)"
MIN_SCORE=80
VERDICT=$(jq -r '.verdict_level' evaluation.json)
SCORE=$(jq -r '.score' evaluation.json)
if [ "$VERDICT" = "rework" ] || [ "$SCORE" -lt "$MIN_SCORE" ]; then
echo "eval-forge gate: FAIL - $VERDICT at $SCORE/100 (bar: no rework, score >= $MIN_SCORE)" >&2
jq -r '.findings[] | select(.severity == "high") | " high (\(.category)): \(.title)"' evaluation.json >&2
jq -r '.rubric_results[] | select(.status == "fail") | " rubric fail: \(.criterion)"' evaluation.json >&2
exit 1
fi
echo "eval-forge gate: PASS - $VERDICT at $SCORE/100"
Pick the bar for the artifact, not the ego: ship-only is right for a gate that
publishes something customer-facing; rework-only (any score) is right for an
agent loop that merely refuses to retry forever. A strict score bar with no
rubric sent is noisy — derived criteria vary between runs, so send your
own rubric when the gate must be reproducible. And remember the evaluator is itself a
model: the gate is a cheap first filter in front of your review, compiler, tests and schema
checks — not a replacement for them.