Evals
Measure whether output is getting better: ctx.score records a score inline, graders score finished runs, and you can filter runs by their scores.
A score is a named evaluation result attached to a run - helpfulness = 0.9, grounded = true,
tone = "friendly". Scores are how you measure output quality over time: a workflow can grade its own
output inline, a person or an agent can grade a finished run after the fact, and you can filter runs by
their scores.
ctx.score - score inline
Call ctx.score from workflow code to record a score on the current run. Like a step, it is durable and
memoized: Duraton writes the score once, so a replay after a crash never records it twice. The score
name is its identity, so you pass no id.
export const grader = defineWorkflow({
name: "grade-reply",
handler: async (ctx) => {
const reply = await ctx.step.ai.generate("draft", { model: "claude-opus-4-8", prompt });
await ctx.score({ name: "helpfulness", value: 0.9, comment: "clear and correct" });
await ctx.score({ name: "grounded", value: 1, dataType: "boolean" });
await ctx.score({ name: "tone", stringValue: "friendly", dataType: "categorical" });
return reply.text;
},
});Prop
Type
dataType | Carried in | Description |
|---|---|---|
numeric | value | A number, e.g. a 0..1 quality score. The default. |
boolean | value | 0 or 1 - a pass/fail check. |
categorical | stringValue | A label, e.g. a rubric bucket. |
ctx.score also takes runId (to score a different run - see deferred
scorers) and source, which is code on this path or llm-judge; human is the
annotation path below and is rejected from ctx.score.
Score a finished run
Grade a run after it completes - a human review, or an agent judging another run's output - with the REST
client (the raw endpoints are in the evals API reference). Each annotation is its own
row (scoring the same name twice records both), and source names who produced it.
await duraton.runs.score(runId, {
name: "human-review",
value: 1,
source: "human",
comment: "looks right",
});source is one of human (an annotation), code (a deterministic scorer - what ctx.score records),
or llm-judge (an LLM-as-judge scorer). It defaults to human over the REST client.
Deferred scorers
A deferred scorer grades a run after it finishes, on its own - no code in the graded workflow. When
any run reaches a terminal state, Duraton emits the reserved system event duraton/run.finished. Any
workflow subscribed to it runs like a normal run (it shows up in the runs table) and can score the finished
run by passing its runId to ctx.score.
import { defineWorkflow, RUN_FINISHED_EVENT, type RunFinishedEvent } from "@duraton/sdk";
export const gradeHelpfulness = defineWorkflow<RunFinishedEvent>({
name: "grade-helpfulness",
// Filter to the workflow you want to grade. This is what keeps the loop from feeding
// itself: this scorer's own run.finished has a different workflow name, so it never matches.
triggers: [{ event: RUN_FINISHED_EVENT, if: "event.data.workflow == 'draft-reply'" }],
handler: async (ctx) => {
const finished = ctx.event.data; // { runId, workflow, app, status, output?, error? }
const judgment = await ctx.step.ai.generate<{ score: number }>("judge", {
model: "claude-opus-4-8",
prompt: `Rate the helpfulness 0..1 of: ${JSON.stringify(finished.output)}`,
output: { type: "object", properties: { score: { type: "number" } }, required: ["score"] },
});
// Score the FINISHED run, not this one. Duraton stamps this workflow as the score's
// scorer, so the graded run shows who judged it.
await ctx.score({
runId: finished.runId,
name: "helpfulness",
value: judgment.output?.score ?? 0,
source: "llm-judge",
});
},
});The duraton/run.finished payload (RunFinishedEvent) carries the finished run's runId, workflow,
app, status (succeeded or failed), and its output (or error). A scorer must filter which
runs it grades - a scorer with no filter would grade every finished run, including other scorers' runs.
Because ctx.score is a durable step, a deferred score is written exactly once even if the scorer replays.
Read and filter by score
Read a run's scores, or filter the runs list to those carrying a named score in a value range.
const scores = await duraton.runs.scores(runId);
const good = await duraton.runs.list({ scoreName: "helpfulness", minScore: 0.5 });In the console, a scored run shows a Scores count column in the runs table (the values open in the run inspector's Scores section), and the runs filter bar has a Score control that narrows the list to a named score within a min/max value range.
Fork-compare - re-run with one change
Fork-compare re-runs a finished run with one declared change - a different model, prompt, or params on a single AI step - so you can compare the outcome against the original. Duraton replays the run from the journal: the steps before the changed step are reused (their memoized output), and execution resumes at the change.
const fork = await duraton.runs.fork(baseRunId, {
step: "draft", // the AI step to change (execution resumes here)
model: "claude-haiku-4-5", // try a cheaper model...
// prompt: "Be more concise.", // ...or a different prompt
// params: { temperature: 0.2 }, // ...or different params
});The fork is a run like any other (it shows up in the runs table), linked back to the base by replayOf
and carrying its declared change in fork. Because the change can't be applied to a stored prompt (Duraton
never persists prompts), it is applied when the changed step re-executes - the workflow reruns and the
override takes effect at that step.
A fork is a shadow run: its side effects render but do not deliver. A ctx.webhook.send or
ctx.step.emit downstream of the change appears as a step in the fork but is not actually sent, so you can
safely compare a change against a production run without firing its webhooks or events again.
Shadowing covers Duraton's own side effects - ctx.webhook.send, ctx.step.emit, and
ctx.step.runWorkflow (which resolves to null rather than spawning a child). Arbitrary I/O inside a
step.run body - a direct database write, say - is invisible to Duraton and is not shadowed. Fork the
workflows whose outward effects go through Duraton.
To compare, read both runs' steps and scores and diff them - the base and the fork share step names, so aligning them is straightforward.
const [baseSteps, forkSteps] = await Promise.all([
duraton.runs.steps(baseRunId),
duraton.runs.steps(fork.id),
]);
const [baseScores, forkScores] = await Promise.all([
duraton.runs.scores(baseRunId),
duraton.runs.scores(fork.id),
]);Datasets + eval runs
A dataset is a named collection of test cases - each an input your workflow runs against and an
optional expected output to grade against. Fanning a dataset through a workflow creates an eval
run-set: one run per item, all graded and compared together.
const ds = await duraton.datasets.create("qa-cases");
await duraton.datasets.addItem(ds.id, { input: { q: "2+2" }, expected: "4" });
await duraton.datasets.addItem(ds.id, { input: { q: "capital of france" }, expected: "Paris" });
// Fan the dataset through a workflow: one shadow run per item.
const { evalSet, runIds } = await duraton.datasets.runEval(ds.id, {
workflow: "qa.answer",
label: "baseline",
});Each eval run is a shadow run - its side effects render but do not deliver (the same as a fork), so evaluating a workflow never fires its real webhooks or events. Inside the handler, the run knows it is an eval run and can grade itself against the item's expected output:
const answer = await ctx.step.ai.generate("answer", { model, prompt: `Question: ${ctx.event.data.q}` });
if (ctx.eval) {
// ctx.eval is set only on an eval run; ctx.eval.expected is the item's expected output.
await ctx.assert.equals(answer.text); // scores boolean "match" vs ctx.eval.expected
await ctx.assert.usedStep("answer"); // scores boolean "used:answer" (tool-choice)
// await ctx.assert.sequence(["retrieve", "answer"]); // step-sequence order
}Each ctx.assert.* check records a boolean score with source: "code" and
returns whether it passed, so they roll up into the run-set's aggregate alongside any ctx.score or
llm-judge grade.
Prop
Type
The assertions read the run's step journal so far, so a check only sees the steps dispatched before the call - place them after the steps they grade.
Read the run-set's aggregate to compare candidates:
const sets = await duraton.datasets.evalRuns(ds.id);
// each set: { label, runCount, scores: [{ name, avg, count }] }The console's datasets view lists a dataset's eval sets with their per-name score averages and compares the two newest sets (base average → candidate average + delta) at run-set granularity. The dataset and eval-run endpoints behind all of this are in the evals API reference.
A dataset item's expected rides ctx.eval.expected, never the run input - so grading a workflow never
pollutes its own prompt with the answer. Shadowing covers the same Duraton-mediated side effects as
fork-compare (arbitrary step.run I/O is not shadowed).
Agents grade runs too
Scoring, fork-compare, and datasets are all reachable from an AI agent as MCP tools - score_run,
list_scores, fork_compare, create_dataset, add_dataset_item, run_eval, list_eval_runs. See
the MCP reference.
AI steps
Make every model call run once: the step.ai reference for generate, wrap, embed, and the durable agent loop, with providers, cost, and cache options.
Python SDK
Write durable agents in Python: the duraton package authors workflows, runs an async runner over connect, calls step.ai, and talks to the REST API.